Compare commits
133 commits
76714815f7
...
843ae1c8c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
843ae1c8c8 | ||
| d614ad2219 | |||
| bfc078d5aa | |||
| c62b105518 | |||
| d060486d0d | |||
| eec1451743 | |||
| 89a48cbaf7 | |||
| fae1804fb1 | |||
| c8d7dd3e55 | |||
| 6417958ccc | |||
| aaa897c8dc | |||
| 951d01dc4d | |||
| 7af65d997b | |||
| 2d1d1c62dc | |||
| 249fd12db0 | |||
| 3a98f70ba5 | |||
| 2cab4b0de4 | |||
| 3f73a36076 | |||
| c49758fc46 | |||
| 4b31578f6c | |||
| e775fe118b | |||
| adb44241d9 | |||
| 8fd2ee067e | |||
| 62b37b9aa2 | |||
| 8edbbac95f | |||
| f29bbb02a2 | |||
| fca0194a7d | |||
| 623543b7bd | |||
| d95d4dc737 | |||
| 12419c5237 | |||
| 29f262e1a1 | |||
| 2b8d898429 | |||
| 76223b04e9 | |||
| bee4a7db66 | |||
| 339d37937a | |||
| c637b6b84f | |||
| 97fcae3e9d | |||
| 2d01c70c16 | |||
| 0a59cf5c33 | |||
| 9a7608f9a1 | |||
| 63040afee7 | |||
| c9d4254152 | |||
| 10ad32eb6f | |||
| e8bcd88ee1 | |||
| 9fc8c3b74a | |||
| 3891c33204 | |||
| 2408978180 | |||
| f681ca98b2 | |||
| e7668f1ef4 | |||
| 1fd1880424 | |||
| d5df2338a7 | |||
| 1c8c5ae83b | |||
| 94bcb5dc8c | |||
| d41d13d122 | |||
| e86c78a0dc | |||
| 8db24405fa | |||
| f3b213ecec | |||
| 68ceaced0c | |||
| b7ef69813b | |||
| 5715a22b0c | |||
| 0f51bc89c3 | |||
| b3b8b31c0f | |||
| e9ed61a8fd | |||
| 50c4ab049d | |||
| 717b8f5676 | |||
| 0d1b776e78 | |||
| cca2ca4632 | |||
| bbededf3b9 | |||
| d44c5bdf94 | |||
| 27b9cbe814 | |||
| 8933ad9d14 | |||
| 17fd5e13d5 | |||
| fec2f7b6f6 | |||
| c86781c32b | |||
| a1684f485c | |||
| 0f20e459e9 | |||
| d9491dea9c | |||
| daaa4dc345 | |||
| 8ffd842c38 | |||
| 1f21afeb72 | |||
| 3cdaa75fc1 | |||
| 482a335d36 | |||
| 68e6c74a67 | |||
| b60ab3f392 | |||
| ede3df12ef | |||
| 6c22d889a1 | |||
| 140e4a9054 | |||
| 1188320844 | |||
| 9d3c72acff | |||
| 7db609deec | |||
| 02245e6684 | |||
| 124857cc9c | |||
| bc2d91f9e7 | |||
| c33199465c | |||
| e1e0469e41 | |||
| f2bcf68da2 | |||
| 17488a6f42 | |||
| a94c0c0b14 | |||
| a23f999eee | |||
| e4e6cfdd47 | |||
| c46365576d | |||
| 376086ae0f | |||
| 5343b78750 | |||
| 32efe380b7 | |||
| a008cf381a | |||
| a5a4d66655 | |||
| 47284fee98 | |||
| 91839dc426 | |||
| be9d12f181 | |||
| 2619e3ea29 | |||
| 056fd04ddf | |||
| 01d901a61d | |||
| d37ba84a74 | |||
| 381e09dd1d | |||
| f3ca492b49 | |||
| 2f8df3f39d | |||
| 3ecbd964ba | |||
| 8430069b45 | |||
| 123227a50e | |||
| 83b104ecf3 | |||
| ec814a8c94 | |||
| 397f7a7975 | |||
| cb932ad6ef | |||
| dbdac5870a | |||
| 01f62297fc | |||
| cbed65de66 | |||
| 3491b4b1ba | |||
| 2315f2588f | |||
| d1fefcca7d | |||
| b5fc03e94f | |||
| ac13a39e7c | |||
| 002d723d0e | |||
| a25263b721 |
148 changed files with 9162 additions and 1543 deletions
|
|
@ -84,7 +84,7 @@ steps:
|
|||
# Fetch dependencies
|
||||
- mix deps.get
|
||||
# Run fast tests (excludes slow/performance and UI tests)
|
||||
- mix test --exclude slow --exclude ui
|
||||
- mix test --exclude slow --exclude ui --max-cases 2
|
||||
|
||||
- name: rebuild-cache
|
||||
image: drillster/drone-volume-cache
|
||||
|
|
@ -273,7 +273,7 @@ environment:
|
|||
|
||||
steps:
|
||||
- name: renovate
|
||||
image: renovate/renovate:43.31
|
||||
image: renovate/renovate:43.35
|
||||
environment:
|
||||
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
|
||||
RENOVATE_TOKEN:
|
||||
|
|
|
|||
15
.env.example
15
.env.example
|
|
@ -22,11 +22,22 @@ ASSOCIATION_NAME="Sportsclub XYZ"
|
|||
# These have defaults in docker-compose.prod.yml, only override if needed
|
||||
# OIDC_CLIENT_ID=mv
|
||||
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
||||
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
|
||||
# OIDC_CLIENT_SECRET=your-rauthy-client-secret
|
||||
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/oidc/callback
|
||||
# OIDC_CLIENT_SECRET=your-oidc-client-secret
|
||||
|
||||
# Optional: OIDC group → Admin role sync (e.g. Authentik groups from profile scope)
|
||||
# If OIDC_ADMIN_GROUP_NAME is set, users in that group get Admin role on registration/sign-in.
|
||||
# OIDC_GROUPS_CLAIM defaults to "groups" (JWT claim name for group list).
|
||||
# OIDC_ADMIN_GROUP_NAME=admin
|
||||
# OIDC_GROUPS_CLAIM=groups
|
||||
|
||||
# Optional: Show only OIDC sign-in on login page (hide password form).
|
||||
# When set to true and OIDC is configured, users see only the Single Sign-On button.
|
||||
# OIDC_ONLY=true
|
||||
|
||||
# Optional: Vereinfacht accounting integration (finance-contacts sync)
|
||||
# If set, these override values from Settings UI; those fields become read-only.
|
||||
# VEREINFACHT_API_URL=https://api.verein.visuel.dev/api/v1
|
||||
# VEREINFACHT_API_KEY=your-api-key
|
||||
# VEREINFACHT_CLUB_ID=2
|
||||
# VEREINFACHT_APP_URL=https://app.verein.visuel.dev
|
||||
|
|
|
|||
|
|
@ -983,9 +983,9 @@ defmodule Mv.Accounts.User do
|
|||
hashed_password_field :hashed_password
|
||||
end
|
||||
|
||||
oauth2 :rauthy do
|
||||
oidc :oidc do
|
||||
client_id fn _, _ ->
|
||||
Application.fetch_env!(:mv, :rauthy)[:client_id]
|
||||
Application.fetch_env!(:mv, :oidc)[:client_id]
|
||||
end
|
||||
# ... other config
|
||||
end
|
||||
|
|
@ -1264,6 +1264,8 @@ end
|
|||
|
||||
### 3.12 Internationalization: Gettext
|
||||
|
||||
**German (de):** Use informal address (“duzen”). All user-facing German text should address the user as “du” (e.g. “Bitte versuche es erneut”, “Deine Einstellungen”), not “Sie”.
|
||||
|
||||
**Define Translations:**
|
||||
|
||||
```elixir
|
||||
|
|
@ -1864,7 +1866,7 @@ authentication do
|
|||
hashed_password_field :hashed_password
|
||||
end
|
||||
|
||||
oauth2 :rauthy do
|
||||
oidc :oidc do
|
||||
# OIDC configuration
|
||||
end
|
||||
end
|
||||
|
|
@ -2091,7 +2093,7 @@ plug :protect_from_forgery
|
|||
|
||||
```elixir
|
||||
# config/runtime.exs
|
||||
config :mv, :rauthy,
|
||||
config :mv, :oidc,
|
||||
client_id: System.get_env("OIDC_CLIENT_ID") || "mv",
|
||||
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
|
||||
base_url: System.get_env("OIDC_BASE_URL")
|
||||
|
|
@ -2847,12 +2849,14 @@ Building accessible applications ensures that all users, including those with di
|
|||
|
||||
**Required Fields:**
|
||||
|
||||
Which member fields are required (asterisk, tooltip, validation) is configured in **Settings** (Memberdata section: edit a member field and set "Required"). The member create/edit form and Member resource validation both read `settings.member_field_required`. Email is always required; other fields default to optional.
|
||||
|
||||
```heex
|
||||
<!-- Mark required fields -->
|
||||
<!-- Mark required fields (value from settings or always true for email) -->
|
||||
<.input
|
||||
field={@form[:first_name]}
|
||||
label={gettext("First Name")}
|
||||
required
|
||||
required={@member_field_required_map[:first_name]}
|
||||
aria-required="true"
|
||||
/>
|
||||
```
|
||||
|
|
|
|||
14
Dockerfile
14
Dockerfile
|
|
@ -7,25 +7,25 @@
|
|||
# This file is based on these images:
|
||||
#
|
||||
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
|
||||
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20250317-slim - for the release image
|
||||
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=trixie-20260202-slim - for the release image
|
||||
# - https://pkgs.org/ - resource for finding needed packages
|
||||
# - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim
|
||||
# - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim
|
||||
#
|
||||
ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim"
|
||||
ARG RUNNER_IMAGE="debian:bullseye-20250317-slim"
|
||||
ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim"
|
||||
ARG RUNNER_IMAGE="debian:trixie-20260202-slim"
|
||||
|
||||
FROM ${BUILDER_IMAGE} AS builder
|
||||
|
||||
# install build dependencies
|
||||
RUN apt-get update -y && apt-get install -y build-essential git \
|
||||
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||
|
||||
# prepare build dir
|
||||
WORKDIR /app
|
||||
|
||||
# install hex + rebar
|
||||
RUN mix local.hex --force && \
|
||||
mix local.rebar --force
|
||||
mix local.rebar --force
|
||||
|
||||
# set build ENV
|
||||
ENV MIX_ENV="prod"
|
||||
|
|
@ -64,7 +64,7 @@ RUN mix release
|
|||
FROM ${RUNNER_IMAGE}
|
||||
|
||||
RUN apt-get update -y && \
|
||||
apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
|
||||
apt-get install -y libstdc++6 openssl libncurses6 locales ca-certificates \
|
||||
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||
|
||||
# Set the locale
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -142,7 +142,7 @@ Mila uses OIDC for Single Sign-On. In development, a local **Rauthy** instance i
|
|||
3. Login with "admin@localhost" and password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in docker-compose.yml
|
||||
4. add client from the admin panel
|
||||
- Client ID: mv
|
||||
- redirect uris: http://localhost:4000/auth/user/rauthy/callback
|
||||
- redirect uris: http://localhost:4000/auth/user/oidc/callback
|
||||
- Authorization Flows: authorization_code
|
||||
- allowed origins: http://localhost:4000
|
||||
- access/id token algortihm: RS256 (EDDSA did not work for me, found just few infos in the ashauthentication docs)
|
||||
|
|
@ -153,13 +153,13 @@ Now you can log in to Mila via OIDC!
|
|||
|
||||
### OIDC with other providers (Authentik, Keycloak, etc.)
|
||||
|
||||
Mila works with any OIDC-compliant provider. The internal strategy is named `:rauthy`, but this is just a name — it works with any provider.
|
||||
Mila works with any OIDC-compliant provider. The internal strategy is named `:oidc` — it works with any OIDC-compliant provider.
|
||||
|
||||
**Important:** The redirect URI must always end with `/auth/user/rauthy/callback`.
|
||||
**Important:** The redirect URI must always end with `/auth/user/oidc/callback`.
|
||||
|
||||
Example for Authentik:
|
||||
1. Create an OAuth2/OpenID Provider in Authentik
|
||||
2. Set the redirect URI to: `https://your-domain.com/auth/user/rauthy/callback`
|
||||
2. Set the redirect URI to: `https://your-domain.com/auth/user/oidc/callback`
|
||||
3. Configure environment variables:
|
||||
```bash
|
||||
DOMAIN=your-domain.com # or PHX_HOST=your-domain.com
|
||||
|
|
@ -168,7 +168,7 @@ Example for Authentik:
|
|||
OIDC_CLIENT_SECRET=your-client-secret # or use OIDC_CLIENT_SECRET_FILE
|
||||
```
|
||||
|
||||
The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/rauthy/callback` if not explicitly set.
|
||||
The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/oidc/callback` if not explicitly set.
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
|
|
@ -238,7 +238,7 @@ For testing the production Docker build locally:
|
|||
# OIDC_CLIENT_ID=mv
|
||||
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
||||
# OIDC_CLIENT_SECRET=<from-your-oidc-provider>
|
||||
# OIDC_REDIRECT_URI is auto-generated as https://{DOMAIN}/auth/user/rauthy/callback
|
||||
# OIDC_REDIRECT_URI is auto-generated as https://{DOMAIN}/auth/user/oidc/callback
|
||||
|
||||
# Alternative: Use _FILE variables for Docker secrets (takes priority over regular vars):
|
||||
# SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
@plugin "../vendor/daisyui-theme" {
|
||||
name: "dark";
|
||||
default: false;
|
||||
prefersdark: true;
|
||||
prefersdark: false;
|
||||
color-scheme: "dark";
|
||||
--color-base-100: oklch(30.33% 0.016 252.42);
|
||||
--color-base-200: oklch(25.26% 0.014 253.1);
|
||||
|
|
@ -99,6 +99,25 @@
|
|||
/* Make LiveView wrapper divs transparent for layout */
|
||||
[data-phx-session] { display: contents }
|
||||
|
||||
/* WCAG 1.4.12 Text Spacing: allow user stylesheets to adjust text spacing in popovers.
|
||||
Popover content (e.g. from DaisyUI dropdown) must not rely on non-overridable inline
|
||||
spacing; use inherited values so custom stylesheets can override. */
|
||||
[popover] {
|
||||
line-height: inherit;
|
||||
letter-spacing: inherit;
|
||||
word-spacing: inherit;
|
||||
}
|
||||
|
||||
/* WCAG 2 AA: success/error text on light backgrounds (e.g. base-200). Use instead of
|
||||
text-success/text-error when contrast ratio of theme colors is insufficient. */
|
||||
.text-success-aa {
|
||||
color: oklch(0.35 0.12 165);
|
||||
}
|
||||
|
||||
.text-error-aa {
|
||||
color: oklch(0.45 0.2 25);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Sidebar Base Styles
|
||||
============================================ */
|
||||
|
|
@ -338,4 +357,36 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Collapsed Sidebar: User Menu Dropdown Richtung
|
||||
============================================ */
|
||||
|
||||
/* Bei eingeklappter Sidebar liegt der Avatar-Button am linken Rand.
|
||||
dropdown-end würde das Menü nach links öffnen (off-screen).
|
||||
Stattdessen nach rechts öffnen (in den Content-Bereich). */
|
||||
#app-layout[data-sidebar-expanded="false"] .dropdown.dropdown-top > ul.dropdown-content {
|
||||
right: auto !important;
|
||||
left: 0 !important;
|
||||
}
|
||||
|
||||
/* Sign-in: hide SSO button and "or" divider when OIDC is not configured.
|
||||
Scoped to #sign-in-page to avoid hiding unrelated elements. */
|
||||
#sign-in-page[data-oidc-configured="false"] [id*="oidc"] {
|
||||
display: none !important;
|
||||
}
|
||||
#sign-in-page[data-oidc-configured="false"] a[href*="oidc"] {
|
||||
display: none !important;
|
||||
}
|
||||
#sign-in-page[data-oidc-configured="false"] .divider {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Sign-in: when OIDC-only mode is on, hide password form and "or" divider (show only SSO). */
|
||||
#sign-in-page[data-oidc-configured="true"][data-oidc-only="true"] [id*="password"] {
|
||||
display: none !important;
|
||||
}
|
||||
#sign-in-page[data-oidc-configured="true"][data-oidc-only="true"] .divider {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* This file is for your main application CSS */
|
||||
|
|
|
|||
|
|
@ -86,6 +86,16 @@ Hooks.SidebarState = {
|
|||
this.setSidebarState(!current)
|
||||
}
|
||||
},
|
||||
|
||||
updated() {
|
||||
// LiveView patches data-sidebar-expanded back to the template default ("true")
|
||||
// on every DOM update. Re-apply the stored state from localStorage after each patch.
|
||||
const expanded = localStorage.getItem('sidebar-expanded') !== 'false'
|
||||
const current = this.el.dataset.sidebarExpanded === 'true'
|
||||
if (current !== expanded) {
|
||||
this.setSidebarState(expanded)
|
||||
}
|
||||
},
|
||||
|
||||
setSidebarState(expanded) {
|
||||
// Convert boolean to string for consistency
|
||||
|
|
@ -228,6 +238,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
|
||||
// Listen for changes to the drawer checkbox
|
||||
drawerToggle.addEventListener("change", () => {
|
||||
// On desktop (lg:drawer-open), the mobile drawer must never open.
|
||||
// The hamburger label is lg:hidden, but guard here as a safety net
|
||||
// against any accidental toggles (e.g. from overlapping elements or JS).
|
||||
if (drawerToggle.checked && window.innerWidth >= 1024) {
|
||||
drawerToggle.checked = false
|
||||
return
|
||||
}
|
||||
const isOpen = drawerToggle.checked
|
||||
updateAriaExpanded()
|
||||
updateSidebarTabIndex(isOpen)
|
||||
|
|
|
|||
|
|
@ -93,11 +93,13 @@ config :mv, :secret_key_base, "ryn7D6ssmIHQFWIks2sFiTGATgwwAR1+3bN8p7fy6qVtB8qnx
|
|||
# Signing Secret for Authentication
|
||||
config :mv, :token_signing_secret, "IwUwi65TrEeExwBXXFPGm2I7889NsL"
|
||||
|
||||
config :mv, :rauthy,
|
||||
client_id: "mv",
|
||||
base_url: "http://localhost:8080/auth/v1",
|
||||
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
|
||||
redirect_uri: "http://localhost:4000/auth/user/rauthy/callback"
|
||||
# OIDC: only use when ENV (or Settings) are set. When all OIDC ENVs are commented out,
|
||||
# do not set defaults here so the SSO button stays hidden and no MissingSecret occurs.
|
||||
# config :mv, :oidc,
|
||||
# client_id: "mv",
|
||||
# base_url: "http://localhost:8080/auth/v1",
|
||||
# client_secret: System.get_env("OIDC_CLIENT_SECRET"),
|
||||
# redirect_uri: "http://localhost:4000/auth/user/oidc/callback"
|
||||
|
||||
# AshAuthentication development configuration
|
||||
config :mv, :session_identifier, :jti
|
||||
|
|
|
|||
|
|
@ -129,8 +129,7 @@ if config_env() == :prod do
|
|||
config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
|
||||
|
||||
# OIDC configuration (works with any OIDC provider: Authentik, Rauthy, Keycloak, etc.)
|
||||
# Note: The strategy is named :rauthy internally, but works with any OIDC provider.
|
||||
# The redirect_uri callback path is always /auth/user/rauthy/callback regardless of provider.
|
||||
# The redirect_uri callback path is /auth/user/oidc/callback.
|
||||
#
|
||||
# Supports OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE for Docker secrets.
|
||||
# OIDC_CLIENT_SECRET is required only if OIDC is being used (indicated by explicit OIDC env vars).
|
||||
|
|
@ -150,9 +149,9 @@ if config_env() == :prod do
|
|||
|
||||
# Build redirect_uri: use OIDC_REDIRECT_URI if set, otherwise build from host.
|
||||
# Uses HTTPS since production runs behind TLS termination.
|
||||
default_redirect_uri = "https://#{host}/auth/user/rauthy/callback"
|
||||
default_redirect_uri = "https://#{host}/auth/user/oidc/callback"
|
||||
|
||||
config :mv, :rauthy,
|
||||
config :mv, :oidc,
|
||||
client_id: oidc_client_id || "mv",
|
||||
base_url: oidc_base_url || "http://localhost:8080/auth/v1",
|
||||
client_secret: client_secret,
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ config :mv, :session_identifier, :unsafe
|
|||
|
||||
config :mv, :require_token_presence_for_authentication, false
|
||||
|
||||
# Use English as default locale in tests so UI tests can assert on English strings.
|
||||
config :mv, :default_locale, "en"
|
||||
|
||||
# Enable SQL Sandbox for async LiveView tests
|
||||
# This flag controls sync vs async behavior in CycleGenerator after_action hooks
|
||||
config :mv, :sql_sandbox, true
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ services:
|
|||
PHX_HOST: "${PHX_HOST:-localhost}"
|
||||
PORT: "4001"
|
||||
PHX_SERVER: "true"
|
||||
# Rauthy OIDC config - use host.docker.internal to reach host services
|
||||
# OIDC config - use host.docker.internal to reach host services
|
||||
OIDC_CLIENT_ID: "mv"
|
||||
OIDC_BASE_URL: "http://host.docker.internal:8080/auth/v1"
|
||||
OIDC_CLIENT_SECRET_FILE: "/run/secrets/oidc_client_secret"
|
||||
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback"
|
||||
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/oidc/callback"
|
||||
secrets:
|
||||
- db_password
|
||||
- secret_key_base
|
||||
|
|
|
|||
|
|
@ -33,14 +33,18 @@
|
|||
- `OIDC_GROUPS_CLAIM` – JWT claim name for group list (default "groups").
|
||||
- Module: Mv.OidcRoleSyncConfig (oidc_admin_group_name/0, oidc_groups_claim/0).
|
||||
|
||||
### Sign-in page (OIDC-only mode)
|
||||
|
||||
- `OIDC_ONLY` (or Settings → OIDC → "Only OIDC sign-in") – When set to true/1/yes and OIDC is configured, the sign-in page shows only the Single Sign-On button (password login is hidden). ENV takes precedence over Settings.
|
||||
|
||||
### Sync Logic
|
||||
|
||||
- Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info) – If admin group configured, sets user role to Admin or Mitglied based on user_info groups.
|
||||
|
||||
### Where It Runs
|
||||
|
||||
1. Registration: register_with_rauthy after_action calls OidcRoleSync.
|
||||
2. Sign-in: sign_in_with_rauthy prepare after_action calls OidcRoleSync for each user.
|
||||
1. Registration: register_with_oidc after_action calls OidcRoleSync.
|
||||
2. Sign-in: sign_in_with_oidc prepare after_action calls OidcRoleSync for each user.
|
||||
|
||||
### Internal Action
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ A **basic CSV member import feature** that allows administrators to upload a CSV
|
|||
- Upload CSV file via LiveView file upload
|
||||
- Parse CSV with bilingual header support for core member fields (English/German)
|
||||
- Auto-detect delimiter (`;` or `,`) using header recognition
|
||||
- Map CSV columns to core member fields (`first_name`, `last_name`, `email`, `street`, `postal_code`, `city`)
|
||||
- Map CSV columns to core member fields (`first_name`, `last_name`, `email`, `street`, `postal_code`, `city`, `country`)
|
||||
- **Import custom field values** - Map CSV columns to existing custom fields by name (unknown custom field columns will be ignored with a warning)
|
||||
- Validate each row (required field: `email`)
|
||||
- Create members via Ash resource (one-by-one, **no background jobs**, processed in chunks of 200 rows via LiveView messages)
|
||||
|
|
@ -149,13 +149,26 @@ A **basic CSV member import feature** that allows administrators to upload a CSV
|
|||
|
||||
**v1 Supported Fields:**
|
||||
|
||||
**Core Member Fields:**
|
||||
**Core Member Fields (all importable):**
|
||||
- `email` / `E-Mail` (required)
|
||||
- `first_name` / `Vorname` (optional)
|
||||
- `last_name` / `Nachname` (optional)
|
||||
- `email` / `E-Mail` (required)
|
||||
- `street` / `Straße` (optional)
|
||||
- `postal_code` / `PLZ` / `Postleitzahl` (optional)
|
||||
- `join_date` / `Beitrittsdatum` (optional, ISO-8601 date)
|
||||
- `exit_date` / `Austrittsdatum` (optional, ISO-8601 date)
|
||||
- `notes` / `Notizen` (optional)
|
||||
- `country` / `Land` / `Staat` (optional)
|
||||
- `city` / `Stadt` (optional)
|
||||
- `street` / `Straße` (optional)
|
||||
- `house_number` / `Hausnummer` / `Nr.` (optional)
|
||||
- `postal_code` / `PLZ` / `Postleitzahl` (optional)
|
||||
- `membership_fee_start_date` / `Beitragsbeginn` (optional, ISO-8601 date)
|
||||
|
||||
Address column order in import/export matches the members overview: country, city, street, house number, postal code.
|
||||
|
||||
**Not supported for import (by design):**
|
||||
- **membership_fee_status** – Computed field (from fee cycles). Not stored; export-only.
|
||||
- **groups** – Many-to-many relationship. Would require resolving group names to IDs; not in current scope.
|
||||
- **membership_fee_type_id** – Foreign key; could be added later (e.g. resolve type name to ID).
|
||||
|
||||
**Custom Fields:**
|
||||
- Any custom field column using the custom field's **name** as the header (e.g., `membership_number`, `birth_date`)
|
||||
|
|
@ -176,9 +189,15 @@ A **basic CSV member import feature** that allows administrators to upload a CSV
|
|||
| `first_name` | `first_name`, `firstname` | `Vorname`, `vorname` |
|
||||
| `last_name` | `last_name`, `lastname`, `surname` | `Nachname`, `nachname`, `Familienname` |
|
||||
| `email` | `email`, `e-mail`, `e_mail` | `E-Mail`, `e-mail`, `e_mail` |
|
||||
| `join_date` | `join date`, `join_date` | `Beitrittsdatum`, `beitritts-datum` |
|
||||
| `exit_date` | `exit date`, `exit_date` | `Austrittsdatum`, `austritts-datum` |
|
||||
| `notes` | `notes` | `Notizen`, `bemerkungen` |
|
||||
| `street` | `street`, `address` | `Straße`, `strasse`, `Strasse` |
|
||||
| `house_number` | `house number`, `house_number`, `house no` | `Hausnummer`, `Nr`, `Nr.`, `Nummer` |
|
||||
| `postal_code` | `postal_code`, `zip`, `postcode` | `PLZ`, `plz`, `Postleitzahl`, `postleitzahl` |
|
||||
| `city` | `city`, `town` | `Stadt`, `stadt`, `Ort` |
|
||||
| `country` | `country` | `Land`, `land`, `Staat`, `staat` |
|
||||
| `membership_fee_start_date` | `membership fee start date`, `membership_fee_start_date`, `fee start` | `Beitragsbeginn`, `beitrags-beginn` |
|
||||
|
||||
**Header Normalization (used consistently for both input headers AND mapping variants):**
|
||||
- Trim whitespace
|
||||
|
|
|
|||
|
|
@ -191,7 +191,8 @@ Settings (1) → MembershipFeeType (0..1)
|
|||
- Join date cannot be in future
|
||||
- Exit date must be after join date
|
||||
- Phone: `+?[0-9\- ]{6,20}`
|
||||
- Postal code: 5 digits
|
||||
- Postal code: optional (no format validation)
|
||||
- Country: optional
|
||||
|
||||
### CustomFieldValue System
|
||||
- Maximum one custom field value per custom field per member
|
||||
|
|
@ -240,7 +241,7 @@ Settings (1) → MembershipFeeType (0..1)
|
|||
### Weighted Fields
|
||||
- **Weight A (highest):** first_name, last_name
|
||||
- **Weight B:** email, notes, group names (from member_groups → groups)
|
||||
- **Weight C:** city, street, house_number, postal_code, custom_field_values
|
||||
- **Weight C:** city, street, house_number, postal_code, country, custom_field_values
|
||||
- **Weight D (lowest):** join_date, exit_date
|
||||
|
||||
### Group Names in Search
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ Table members {
|
|||
street text [null, note: 'Street name']
|
||||
house_number text [null, note: 'House number']
|
||||
postal_code text [null, note: '5-digit German postal code']
|
||||
country text [null, note: 'Country of residence']
|
||||
search_vector tsvector [null, note: 'Full-text search index (auto-generated)']
|
||||
membership_fee_type_id uuid [null, note: 'FK to membership_fee_types - assigned fee type']
|
||||
membership_fee_start_date date [null, note: 'Date from which membership fees should be calculated']
|
||||
|
|
@ -188,7 +189,8 @@ Table members {
|
|||
- email: 5-254 characters, valid email format (required)
|
||||
- join_date: cannot be in future
|
||||
- exit_date: must be after join_date (if both present)
|
||||
- postal_code: exactly 5 digits (if present)
|
||||
- postal_code: optional (no format validation)
|
||||
- country: optional
|
||||
'''
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -886,7 +886,7 @@ just regen-migrations <name>
|
|||
**Checklist:**
|
||||
1. ✅ Rauthy running: `docker compose ps`
|
||||
2. ✅ Client created in Rauthy admin panel
|
||||
3. ✅ Redirect URI matches exactly: `http://localhost:4000/auth/user/rauthy/callback`
|
||||
3. ✅ Redirect URI matches exactly: `http://localhost:4000/auth/user/oidc/callback`
|
||||
4. ✅ OIDC_CLIENT_SECRET in .env
|
||||
5. ✅ App restarted after .env update
|
||||
|
||||
|
|
|
|||
|
|
@ -501,8 +501,8 @@ Since this is a **Phoenix LiveView** application with **Ash Framework**, we have
|
|||
|--------|-------|---------|------|---------|----------|
|
||||
| `GET` | `/auth/user/password/sign_in` | Show password login form | 🔓 | - | HTML form |
|
||||
| `POST` | `/auth/user/password/sign_in` | Submit password login | 🔓 | `{email, password}` | Redirect + session cookie |
|
||||
| `GET` | `/auth/user/rauthy` | Initiate OIDC flow | 🔓 | - | Redirect to Rauthy |
|
||||
| `GET` | `/auth/user/rauthy/callback` | Handle OIDC callback | 🔓 | `{code, state}` | Redirect + session cookie |
|
||||
| `GET` | `/auth/user/oidc` | Initiate OIDC flow | 🔓 | - | Redirect to Rauthy |
|
||||
| `GET` | `/auth/user/oidc/callback` | Handle OIDC callback | 🔓 | `{code, state}` | Redirect + session cookie |
|
||||
| `POST` | `/auth/user/sign_out` | Sign out user | 🔐 | - | Redirect to login |
|
||||
| `GET` | `/auth/link-oidc-account` | OIDC account linking (password verification) | 🔓 | - | LiveView form | ✅ Implemented |
|
||||
| `GET` | `/auth/user/password/reset` | Show password reset form | 🔓 | - | HTML form |
|
||||
|
|
@ -515,9 +515,9 @@ Since this is a **Phoenix LiveView** application with **Ash Framework**, we have
|
|||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `User` | `:sign_in_with_password` | Password authentication | 🔓 | `{email, password}` | `{:ok, user}` or `{:error, reason}` |
|
||||
| `User` | `:sign_in_with_rauthy` | OIDC authentication | 🔓 | `{oidc_id, email, user_info}` | `{:ok, user}` or `{:error, reason}` |
|
||||
| `User` | `:sign_in_with_oidc` | OIDC authentication | 🔓 | `{oidc_id, email, user_info}` | `{:ok, user}` or `{:error, reason}` |
|
||||
| `User` | `:register_with_password` | Create user with password | 🔓 | `{email, password}` | `{:ok, user}` |
|
||||
| `User` | `:register_with_rauthy` | Create user via OIDC | 🔓 | `{oidc_id, email}` | `{:ok, user}` |
|
||||
| `User` | `:register_with_oidc` | Create user via OIDC | 🔓 | `{oidc_id, email}` | `{:ok, user}` |
|
||||
| `User` | `:request_password_reset` | Generate reset token | 🔓 | `{email}` | `{:ok, token}` |
|
||||
| `User` | `:reset_password` | Reset password with token | 🔓 | `{token, password}` | `{:ok, user}` |
|
||||
| `Token` | `:revoke` | Revoke authentication token | 🔐 | `{jti}` | `{:ok, token}` |
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ This feature implements secure account linking between password-based accounts a
|
|||
|
||||
#### 1. Security Fix: `lib/accounts/user.ex`
|
||||
|
||||
**Change**: The `sign_in_with_rauthy` action now filters by `oidc_id` instead of `email`.
|
||||
**Change**: The `sign_in_with_oidc` action now filters by `oidc_id` instead of `email`.
|
||||
|
||||
```elixir
|
||||
read :sign_in_with_rauthy do
|
||||
read :sign_in_with_oidc do
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ defmodule Mv.Accounts do
|
|||
## Public API
|
||||
The domain exposes these main actions:
|
||||
- User CRUD: `create_user/1`, `list_users/0`, `update_user/2`, `destroy_user/1`
|
||||
- Authentication: `create_register_with_rauthy/1`, `read_sign_in_with_rauthy/1`
|
||||
- Authentication: `create_register_with_oidc/1`, `read_sign_in_with_oidc/1`
|
||||
"""
|
||||
use Ash.Domain,
|
||||
extensions: [AshAdmin.Domain, AshPhoenix]
|
||||
|
|
@ -24,8 +24,8 @@ defmodule Mv.Accounts do
|
|||
define :list_users, action: :read
|
||||
define :update_user, action: :update_user
|
||||
define :destroy_user, action: :destroy
|
||||
define :create_register_with_rauthy, action: :register_with_rauthy
|
||||
define :read_sign_in_with_rauthy, action: :sign_in_with_rauthy
|
||||
define :create_register_with_oidc, action: :register_with_oidc
|
||||
define :read_sign_in_with_oidc, action: :sign_in_with_oidc
|
||||
end
|
||||
|
||||
resource Mv.Accounts.Token
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ defmodule Mv.Accounts.User do
|
|||
|
||||
@doc """
|
||||
AshAuthentication specific: Defines the strategies we want to use for authentication.
|
||||
Currently password and SSO with Rauthy as OIDC provider
|
||||
Currently password and SSO via OIDC (supports any provider: Authentik, Rauthy, Keycloak, etc.)
|
||||
"""
|
||||
authentication do
|
||||
session_identifier Application.compile_env!(:mv, :session_identifier)
|
||||
|
|
@ -52,7 +52,7 @@ defmodule Mv.Accounts.User do
|
|||
end
|
||||
|
||||
strategies do
|
||||
oidc :rauthy do
|
||||
oidc :oidc do
|
||||
client_id Mv.Secrets
|
||||
base_url Mv.Secrets
|
||||
redirect_uri Mv.Secrets
|
||||
|
|
@ -88,7 +88,7 @@ defmodule Mv.Accounts.User do
|
|||
# Always use one of these explicit create actions instead:
|
||||
# - :create_user (for manual user creation with optional member link)
|
||||
# - :register_with_password (for password-based registration)
|
||||
# - :register_with_rauthy (for OIDC-based registration)
|
||||
# - :register_with_oidc (for OIDC-based registration)
|
||||
defaults [:read]
|
||||
|
||||
destroy :destroy do
|
||||
|
|
@ -118,6 +118,8 @@ defmodule Mv.Accounts.User do
|
|||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where [changing(:email)]
|
||||
end
|
||||
|
||||
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||
end
|
||||
|
||||
create :create_user do
|
||||
|
|
@ -145,6 +147,8 @@ defmodule Mv.Accounts.User do
|
|||
|
||||
# Sync user email to member when linking (User → Member)
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
||||
|
||||
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||
end
|
||||
|
||||
update :update_user do
|
||||
|
|
@ -178,6 +182,8 @@ defmodule Mv.Accounts.User do
|
|||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where any([changing(:email), changing(:member)])
|
||||
end
|
||||
|
||||
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||
end
|
||||
|
||||
# Internal update used only by SystemActor/bootstrap and tests to assign role to system user.
|
||||
|
|
@ -211,6 +217,8 @@ defmodule Mv.Accounts.User do
|
|||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where [changing(:email)]
|
||||
end
|
||||
|
||||
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||
end
|
||||
|
||||
# Action to link an OIDC account to an existing password-only user
|
||||
|
|
@ -248,6 +256,8 @@ defmodule Mv.Accounts.User do
|
|||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where [changing(:email)]
|
||||
end
|
||||
|
||||
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||
end
|
||||
|
||||
read :get_by_subject do
|
||||
|
|
@ -257,7 +267,7 @@ defmodule Mv.Accounts.User do
|
|||
prepare AshAuthentication.Preparations.FilterBySubject
|
||||
end
|
||||
|
||||
read :sign_in_with_rauthy do
|
||||
read :sign_in_with_oidc do
|
||||
# Single record expected; required for AshAuthentication OAuth2 strategy (returns list of 0 or 1).
|
||||
get? true
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
|
|
@ -292,7 +302,7 @@ defmodule Mv.Accounts.User do
|
|||
end)
|
||||
end
|
||||
|
||||
create :register_with_rauthy do
|
||||
create :register_with_oidc do
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
upsert? true
|
||||
|
|
@ -328,6 +338,8 @@ defmodule Mv.Accounts.User do
|
|||
# Sync user email to member when linking (User → Member)
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
||||
|
||||
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||
|
||||
# Sync role from OIDC groups (e.g. admin group → Admin role) after user is created/updated
|
||||
change fn changeset, _ctx ->
|
||||
user_info = Ash.Changeset.get_argument(changeset, :user_info)
|
||||
|
|
|
|||
|
|
@ -52,7 +52,8 @@ defmodule Mv.Membership.CustomField do
|
|||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
authorizers: [Ash.Policy.Authorizer],
|
||||
primary_read_warning?: false
|
||||
|
||||
postgres do
|
||||
table "custom_fields"
|
||||
|
|
@ -60,9 +61,13 @@ defmodule Mv.Membership.CustomField do
|
|||
end
|
||||
|
||||
actions do
|
||||
defaults [:read]
|
||||
default_accept [:name, :value_type, :description, :required, :show_in_overview]
|
||||
|
||||
read :read do
|
||||
primary? true
|
||||
prepare build(sort: [name: :asc])
|
||||
end
|
||||
|
||||
create :create do
|
||||
accept [:name, :value_type, :description, :required, :show_in_overview]
|
||||
change Mv.Membership.Changes.GenerateSlug
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ defmodule Mv.Membership.Member do
|
|||
## Validations
|
||||
- Required: email (all other fields are optional)
|
||||
- Email format validation (using EctoCommons.EmailValidator)
|
||||
- Postal code format: exactly 5 digits (German format)
|
||||
- Date validations: join_date not in future, exit_date after join_date
|
||||
- Email uniqueness: prevents conflicts with unlinked users
|
||||
- Linked member email change: only admins or the linked user may change a linked member's email (see `Mv.Membership.Member.Validations.EmailChangePermission`)
|
||||
|
|
@ -117,6 +116,9 @@ defmodule Mv.Membership.Member do
|
|||
# Requires both join_date and membership_fee_type_id to be present
|
||||
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
||||
|
||||
# Sync member to Vereinfacht as finance contact (if configured)
|
||||
change Mv.Vereinfacht.Changes.SyncContact
|
||||
|
||||
# Trigger cycle generation after member creation
|
||||
# Only runs if membership_fee_type_id is set
|
||||
# Note: Cycle generation runs asynchronously to not block the action,
|
||||
|
|
@ -190,6 +192,9 @@ defmodule Mv.Membership.Member do
|
|||
where [changing(:membership_fee_type_id)]
|
||||
end
|
||||
|
||||
# Sync member to Vereinfacht as finance contact (if configured)
|
||||
change Mv.Vereinfacht.Changes.SyncContact
|
||||
|
||||
# Trigger cycle regeneration when membership_fee_type_id changes
|
||||
# This deletes future unpaid cycles and regenerates them with the new type/amount
|
||||
# Note: Cycle regeneration runs synchronously in the same transaction to ensure atomicity
|
||||
|
|
@ -243,6 +248,13 @@ defmodule Mv.Membership.Member do
|
|||
end)
|
||||
end
|
||||
|
||||
# Internal: set vereinfacht_contact_id after syncing with Vereinfacht API.
|
||||
# Not exposed via code interface; used only by Mv.Vereinfacht.Changes.SyncContact.
|
||||
update :set_vereinfacht_contact_id do
|
||||
require_atomic? false
|
||||
accept [:vereinfacht_contact_id]
|
||||
end
|
||||
|
||||
# Action to handle fuzzy search on specific fields
|
||||
read :search do
|
||||
argument :query, :string, allow_nil?: true
|
||||
|
|
@ -320,6 +332,12 @@ defmodule Mv.Membership.Member do
|
|||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
|
||||
# Internal sync action: only SystemActor may set vereinfacht_contact_id (used by SyncContact change).
|
||||
policy action(:set_vereinfacht_contact_id) do
|
||||
description "Only system actor may set Vereinfacht contact ID"
|
||||
authorize_if Mv.Authorization.Checks.ActorIsSystemUser
|
||||
end
|
||||
|
||||
# CREATE/UPDATE: Forbid member–user link unless admin, then check permissions
|
||||
# ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty).
|
||||
# HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all.
|
||||
|
|
@ -458,11 +476,6 @@ defmodule Mv.Membership.Member do
|
|||
where: [present([:join_date, :exit_date])],
|
||||
message: "cannot be before join date"
|
||||
|
||||
# Postal code format (only if set)
|
||||
validate match(:postal_code, ~r/^\d{5}$/),
|
||||
where: [present(:postal_code)],
|
||||
message: "must consist of 5 digits"
|
||||
|
||||
# Email validation with EctoCommons.EmailValidator
|
||||
validate fn changeset, _ ->
|
||||
email = Ash.Changeset.get_attribute(changeset, :email)
|
||||
|
|
@ -481,48 +494,97 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
end
|
||||
|
||||
# Validate required custom fields (actor from validation context only; no fallback)
|
||||
# Validate required custom fields (actor from validation context only; no fallback).
|
||||
# Only for create_member/update_member; skip for set_vereinfacht_contact_id (internal sync
|
||||
# only sets vereinfacht_contact_id; custom fields were already validated and saved).
|
||||
validate fn changeset, context ->
|
||||
provided_values = provided_custom_field_values(changeset)
|
||||
actor = context.actor
|
||||
provided_values = provided_custom_field_values(changeset)
|
||||
actor = context.actor
|
||||
|
||||
case Mv.Membership.list_required_custom_fields(actor: actor) do
|
||||
{:ok, required_custom_fields} ->
|
||||
missing_fields = missing_required_fields(required_custom_fields, provided_values)
|
||||
case Mv.Membership.list_required_custom_fields(actor: actor) do
|
||||
{:ok, required_custom_fields} ->
|
||||
missing_fields =
|
||||
missing_required_fields(required_custom_fields, provided_values)
|
||||
|
||||
if Enum.empty?(missing_fields) do
|
||||
:ok
|
||||
else
|
||||
build_custom_field_validation_error(missing_fields)
|
||||
end
|
||||
if Enum.empty?(missing_fields) do
|
||||
:ok
|
||||
else
|
||||
build_custom_field_validation_error(missing_fields)
|
||||
end
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
Logger.warning(
|
||||
"Required custom fields validation: actor not authorized to read CustomField"
|
||||
)
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
Logger.warning(
|
||||
"Required custom fields validation: actor not authorized to read CustomField"
|
||||
)
|
||||
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"You are not authorized to perform this action. Please sign in again or contact support."}
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"You are not authorized to perform this action. Please sign in again or contact support."}
|
||||
|
||||
{:error, :missing_actor} ->
|
||||
Logger.warning("Required custom fields validation: no actor in context")
|
||||
{:error, :missing_actor} ->
|
||||
Logger.warning("Required custom fields validation: no actor in context")
|
||||
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"You are not authorized to perform this action. Please sign in again or contact support."}
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"You are not authorized to perform this action. Please sign in again or contact support."}
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error(
|
||||
"Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed."
|
||||
)
|
||||
{:error, error} ->
|
||||
Logger.error(
|
||||
"Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed."
|
||||
)
|
||||
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"Unable to validate required custom fields. Please try again or contact support."}
|
||||
{:error,
|
||||
field: :custom_field_values,
|
||||
message:
|
||||
"Unable to validate required custom fields. Please try again or contact support."}
|
||||
end
|
||||
end,
|
||||
where: [action_is([:create_member, :update_member])]
|
||||
|
||||
# Validate member fields that are marked as required in settings or by Vereinfacht.
|
||||
# When settings cannot be loaded, we still enforce email + Vereinfacht-required fields.
|
||||
validate fn changeset, _context ->
|
||||
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
|
||||
|
||||
required_fields =
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, settings} ->
|
||||
required_config = settings.member_field_required || %{}
|
||||
normalized = VisibilityConfig.normalize(required_config)
|
||||
|
||||
Enum.filter(Mv.Constants.member_fields(), fn field ->
|
||||
field == :email ||
|
||||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
|
||||
Map.get(normalized, field, false)
|
||||
end)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"Member required-fields validation: could not load settings (#{inspect(reason)}). " <>
|
||||
"Enforcing only email and Vereinfacht-required fields."
|
||||
)
|
||||
|
||||
Enum.filter(Mv.Constants.member_fields(), fn field ->
|
||||
field == :email ||
|
||||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field))
|
||||
end)
|
||||
end
|
||||
|
||||
missing =
|
||||
Enum.filter(required_fields, fn field ->
|
||||
value = Ash.Changeset.get_attribute(changeset, field)
|
||||
not member_field_value_present?(field, value)
|
||||
end)
|
||||
|
||||
if Enum.empty?(missing) do
|
||||
:ok
|
||||
else
|
||||
field = hd(missing)
|
||||
|
||||
{:error,
|
||||
field: field, message: Gettext.dgettext(MvWeb.Gettext, "default", "can't be blank")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -580,6 +642,10 @@ defmodule Mv.Membership.Member do
|
|||
allow_nil? true
|
||||
end
|
||||
|
||||
attribute :country, :string do
|
||||
allow_nil? true
|
||||
end
|
||||
|
||||
attribute :search_vector, AshPostgres.Tsvector,
|
||||
writable?: false,
|
||||
public?: false,
|
||||
|
|
@ -593,6 +659,14 @@ defmodule Mv.Membership.Member do
|
|||
public? true
|
||||
description "Date from which membership fees should be calculated"
|
||||
end
|
||||
|
||||
# Vereinfacht accounting software integration: ID of the finance contact synced via API.
|
||||
# Set by Mv.Vereinfacht.Changes.SyncContact; not accepted in create/update actions.
|
||||
attribute :vereinfacht_contact_id, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "ID of the finance contact in Vereinfacht (set by sync)"
|
||||
end
|
||||
end
|
||||
|
||||
relationships do
|
||||
|
|
@ -1173,7 +1247,8 @@ defmodule Mv.Membership.Member do
|
|||
contains(postal_code, ^query) or
|
||||
contains(house_number, ^query) or
|
||||
contains(email, ^query) or
|
||||
contains(city, ^query)
|
||||
contains(city, ^query) or
|
||||
contains(country, ^query)
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -1273,17 +1348,24 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
end
|
||||
|
||||
# Extracts custom field values from existing member data (update scenario)
|
||||
# Extracts custom field values from existing member data (update scenario).
|
||||
# Actor must come from context; no system-actor fallback (per guidelines).
|
||||
# When no actor is present we skip the load and return empty map.
|
||||
defp extract_existing_values(member_data, changeset) do
|
||||
actor = Map.get(changeset.context, :actor)
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.load(member_data, :custom_field_values, opts) do
|
||||
{:ok, %{custom_field_values: existing_values}} ->
|
||||
Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)
|
||||
|
||||
_ ->
|
||||
case Map.get(changeset.context, :actor) do
|
||||
nil ->
|
||||
%{}
|
||||
|
||||
actor ->
|
||||
opts = Helpers.ash_actor_opts(actor)
|
||||
|
||||
case Ash.load(member_data, :custom_field_values, opts) do
|
||||
{:ok, %{custom_field_values: existing_values}} ->
|
||||
Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -1386,4 +1468,14 @@ defmodule Mv.Membership.Member do
|
|||
defp value_present?(_value, :email), do: false
|
||||
|
||||
defp value_present?(_value, _type), do: false
|
||||
|
||||
# Used by member-field-required validation (settings-driven required fields)
|
||||
defp member_field_value_present?(_field, nil), do: false
|
||||
|
||||
defp member_field_value_present?(_, value) when is_binary(value),
|
||||
do: String.trim(value) != ""
|
||||
|
||||
defp member_field_value_present?(_, %Date{}), do: true
|
||||
defp member_field_value_present?(_, value) when is_struct(value, Date), do: true
|
||||
defp member_field_value_present?(_, _), do: false
|
||||
end
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ defmodule Mv.Membership do
|
|||
|
||||
define :update_single_member_field_visibility,
|
||||
action: :update_single_member_field_visibility
|
||||
|
||||
define :update_single_member_field, action: :update_single_member_field
|
||||
end
|
||||
|
||||
resource Mv.Membership.Group do
|
||||
|
|
@ -257,6 +259,46 @@ defmodule Mv.Membership do
|
|||
|> Ash.update(domain: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Atomically updates visibility and required for a single member field.
|
||||
|
||||
Updates both `member_field_visibility` and `member_field_required` in one
|
||||
operation. Use this when saving from the member field settings form.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `settings` - The settings record to update
|
||||
- `field` - The member field name as a string (e.g., "first_name", "street")
|
||||
- `show_in_overview` - Boolean value indicating visibility in member overview
|
||||
- `required` - Boolean value indicating whether the field is required in member forms
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, updated_settings}` - Successfully updated settings
|
||||
- `{:error, error}` - Validation or update error
|
||||
|
||||
## Examples
|
||||
|
||||
iex> {:ok, settings} = Mv.Membership.get_settings()
|
||||
iex> {:ok, updated} = Mv.Membership.update_single_member_field(settings, field: "first_name", show_in_overview: true, required: true)
|
||||
iex> updated.member_field_required["first_name"]
|
||||
true
|
||||
|
||||
"""
|
||||
def update_single_member_field(settings,
|
||||
field: field,
|
||||
show_in_overview: show_in_overview,
|
||||
required: required
|
||||
) do
|
||||
settings
|
||||
|> Ash.Changeset.new()
|
||||
|> Ash.Changeset.set_argument(:field, field)
|
||||
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
||||
|> Ash.Changeset.set_argument(:required, required)
|
||||
|> Ash.Changeset.for_update(:update_single_member_field, %{})
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a group by its slug.
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ defmodule Mv.Membership.Setting do
|
|||
- `club_name` - The name of the association/club (required, cannot be empty)
|
||||
- `member_field_visibility` - JSONB map storing visibility configuration for member fields
|
||||
(e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`.
|
||||
- `member_field_required` - JSONB map storing which member fields are required in forms
|
||||
(e.g., `%{"first_name" => true, "last_name" => true}`). Email is always required; other fields default to optional.
|
||||
- `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true)
|
||||
- `default_membership_fee_type_id` - Default membership fee type for new members (optional)
|
||||
|
||||
|
|
@ -42,6 +44,9 @@ defmodule Mv.Membership.Setting do
|
|||
# Update member field visibility
|
||||
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
|
||||
|
||||
# Update visibility and required for a single member field (e.g. from settings UI)
|
||||
{:ok, updated} = Mv.Membership.update_single_member_field(settings, field: "first_name", show_in_overview: true, required: true)
|
||||
|
||||
# Update membership fee settings
|
||||
{:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false})
|
||||
"""
|
||||
|
|
@ -68,8 +73,20 @@ defmodule Mv.Membership.Setting do
|
|||
accept [
|
||||
:club_name,
|
||||
:member_field_visibility,
|
||||
:member_field_required,
|
||||
:include_joining_cycle,
|
||||
:default_membership_fee_type_id
|
||||
:default_membership_fee_type_id,
|
||||
:vereinfacht_api_url,
|
||||
:vereinfacht_api_key,
|
||||
:vereinfacht_club_id,
|
||||
:vereinfacht_app_url,
|
||||
:oidc_client_id,
|
||||
:oidc_base_url,
|
||||
:oidc_redirect_uri,
|
||||
:oidc_client_secret,
|
||||
:oidc_admin_group_name,
|
||||
:oidc_groups_claim,
|
||||
:oidc_only
|
||||
]
|
||||
end
|
||||
|
||||
|
|
@ -80,8 +97,20 @@ defmodule Mv.Membership.Setting do
|
|||
accept [
|
||||
:club_name,
|
||||
:member_field_visibility,
|
||||
:member_field_required,
|
||||
:include_joining_cycle,
|
||||
:default_membership_fee_type_id
|
||||
:default_membership_fee_type_id,
|
||||
:vereinfacht_api_url,
|
||||
:vereinfacht_api_key,
|
||||
:vereinfacht_club_id,
|
||||
:vereinfacht_app_url,
|
||||
:oidc_client_id,
|
||||
:oidc_base_url,
|
||||
:oidc_redirect_uri,
|
||||
:oidc_client_secret,
|
||||
:oidc_admin_group_name,
|
||||
:oidc_groups_claim,
|
||||
:oidc_only
|
||||
]
|
||||
end
|
||||
|
||||
|
|
@ -101,6 +130,17 @@ defmodule Mv.Membership.Setting do
|
|||
change Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility
|
||||
end
|
||||
|
||||
update :update_single_member_field do
|
||||
description "Atomically updates visibility and required for a single member field"
|
||||
require_atomic? false
|
||||
|
||||
argument :field, :string, allow_nil?: false
|
||||
argument :show_in_overview, :boolean, allow_nil?: false
|
||||
argument :required, :boolean, allow_nil?: false
|
||||
|
||||
change Mv.Membership.Setting.Changes.UpdateSingleMemberField
|
||||
end
|
||||
|
||||
update :update_membership_fee_settings do
|
||||
description "Updates the membership fee configuration"
|
||||
require_atomic? false
|
||||
|
|
@ -154,6 +194,44 @@ defmodule Mv.Membership.Setting do
|
|||
end,
|
||||
on: [:create, :update]
|
||||
|
||||
# Validate member_field_required map structure and content
|
||||
validate fn changeset, _context ->
|
||||
required_config = Ash.Changeset.get_attribute(changeset, :member_field_required)
|
||||
|
||||
if required_config && is_map(required_config) do
|
||||
invalid_values =
|
||||
Enum.filter(required_config, fn {_key, value} ->
|
||||
not is_boolean(value)
|
||||
end)
|
||||
|
||||
valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
invalid_keys =
|
||||
Enum.filter(required_config, fn {key, _value} ->
|
||||
key not in valid_field_strings
|
||||
end)
|
||||
|> Enum.map(fn {key, _value} -> key end)
|
||||
|
||||
cond do
|
||||
not Enum.empty?(invalid_values) ->
|
||||
{:error,
|
||||
field: :member_field_required,
|
||||
message: "All values in member_field_required must be booleans"}
|
||||
|
||||
not Enum.empty?(invalid_keys) ->
|
||||
{:error,
|
||||
field: :member_field_required,
|
||||
message: "Invalid member field keys: #{inspect(invalid_keys)}"}
|
||||
|
||||
true ->
|
||||
:ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:create, :update]
|
||||
|
||||
# Validate default_membership_fee_type_id exists if set
|
||||
validate fn changeset, context ->
|
||||
fee_type_id =
|
||||
|
|
@ -211,6 +289,12 @@ defmodule Mv.Membership.Setting do
|
|||
description:
|
||||
"Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
|
||||
|
||||
attribute :member_field_required, :map,
|
||||
allow_nil?: true,
|
||||
public?: true,
|
||||
description:
|
||||
"Configuration for which member fields are required in forms (JSONB map). Keys are member field names (strings), values are booleans. Email is always required."
|
||||
|
||||
# Membership fee settings
|
||||
attribute :include_joining_cycle, :boolean do
|
||||
allow_nil? false
|
||||
|
|
@ -225,6 +309,79 @@ defmodule Mv.Membership.Setting do
|
|||
description "Default membership fee type ID for new members"
|
||||
end
|
||||
|
||||
# Vereinfacht accounting software integration (can be overridden by ENV)
|
||||
attribute :vereinfacht_api_url, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "Vereinfacht API base URL (e.g. https://api.verein.visuel.dev/api/v1)"
|
||||
end
|
||||
|
||||
attribute :vereinfacht_api_key, :string do
|
||||
allow_nil? true
|
||||
public? false
|
||||
description "Vereinfacht API key (Bearer token)"
|
||||
sensitive? true
|
||||
end
|
||||
|
||||
attribute :vereinfacht_club_id, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "Vereinfacht club ID for multi-tenancy"
|
||||
end
|
||||
|
||||
attribute :vereinfacht_app_url, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
|
||||
description "Vereinfacht app base URL for contact view links (e.g. https://app.verein.visuel.dev)"
|
||||
end
|
||||
|
||||
# OIDC authentication (can be overridden by ENV)
|
||||
attribute :oidc_client_id, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "OIDC client ID (e.g. from OIDC_CLIENT_ID)"
|
||||
end
|
||||
|
||||
attribute :oidc_base_url, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "OIDC provider base URL (e.g. from OIDC_BASE_URL)"
|
||||
end
|
||||
|
||||
attribute :oidc_redirect_uri, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "OIDC redirect URI for callback (e.g. from OIDC_REDIRECT_URI)"
|
||||
end
|
||||
|
||||
attribute :oidc_client_secret, :string do
|
||||
allow_nil? true
|
||||
public? false
|
||||
description "OIDC client secret (e.g. from OIDC_CLIENT_SECRET)"
|
||||
sensitive? true
|
||||
end
|
||||
|
||||
attribute :oidc_admin_group_name, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "OIDC group name that maps to Admin role (e.g. from OIDC_ADMIN_GROUP_NAME)"
|
||||
end
|
||||
|
||||
attribute :oidc_groups_claim, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
description "JWT claim name for group list (e.g. from OIDC_GROUPS_CLAIM, default 'groups')"
|
||||
end
|
||||
|
||||
attribute :oidc_only, :boolean do
|
||||
allow_nil? false
|
||||
default false
|
||||
public? true
|
||||
|
||||
description "When true and OIDC is configured, sign-in shows only OIDC (password login hidden)"
|
||||
end
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
|
|
|
|||
179
lib/membership/setting/changes/update_single_member_field.ex
Normal file
179
lib/membership/setting/changes/update_single_member_field.ex
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do
|
||||
@moduledoc """
|
||||
Ash change that atomically updates visibility and required for a single member field.
|
||||
|
||||
Updates both `member_field_visibility` and `member_field_required` JSONB maps
|
||||
in one SQL UPDATE to avoid lost updates when saving from the settings UI.
|
||||
|
||||
## Arguments
|
||||
- `field` - The member field name as a string (e.g., "street", "first_name")
|
||||
- `show_in_overview` - Boolean value indicating visibility in member overview
|
||||
- `required` - Boolean value indicating whether the field is required in member forms
|
||||
|
||||
## Example
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_single_member_field, %{},
|
||||
arguments: %{field: "first_name", show_in_overview: true, required: true}
|
||||
)
|
||||
|> Ash.update(domain: Mv.Membership)
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias Ash.Error.Invalid
|
||||
alias Ecto.Adapters.SQL
|
||||
require Logger
|
||||
|
||||
def change(changeset, _opts, _context) do
|
||||
with {:ok, field} <- get_and_validate_field(changeset),
|
||||
{:ok, show_in_overview} <- get_and_validate_boolean(changeset, :show_in_overview),
|
||||
{:ok, required} <- get_and_validate_boolean(changeset, :required) do
|
||||
add_after_action(changeset, field, show_in_overview, required)
|
||||
else
|
||||
{:error, updated_changeset} -> updated_changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp get_and_validate_field(changeset) do
|
||||
case Ash.Changeset.get_argument(changeset, :field) do
|
||||
nil ->
|
||||
{:error,
|
||||
add_error(changeset,
|
||||
field: :field,
|
||||
message: "field argument is required"
|
||||
)}
|
||||
|
||||
field ->
|
||||
valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
if field in valid_fields do
|
||||
{:ok, field}
|
||||
else
|
||||
{:error,
|
||||
add_error(
|
||||
changeset,
|
||||
field: :field,
|
||||
message: "Invalid member field: #{field}"
|
||||
)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_and_validate_boolean(changeset, :show_in_overview = arg_name) do
|
||||
do_validate_boolean(changeset, arg_name, :show_in_overview)
|
||||
end
|
||||
|
||||
defp get_and_validate_boolean(changeset, :required = arg_name) do
|
||||
do_validate_boolean(changeset, arg_name, :member_field_required)
|
||||
end
|
||||
|
||||
defp do_validate_boolean(changeset, arg_name, error_field) do
|
||||
case Ash.Changeset.get_argument(changeset, arg_name) do
|
||||
nil ->
|
||||
{:error,
|
||||
add_error(
|
||||
changeset,
|
||||
field: error_field,
|
||||
message: "#{arg_name} argument is required"
|
||||
)}
|
||||
|
||||
value when is_boolean(value) ->
|
||||
{:ok, value}
|
||||
|
||||
_ ->
|
||||
{:error,
|
||||
add_error(
|
||||
changeset,
|
||||
field: error_field,
|
||||
message: "#{arg_name} must be a boolean"
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp add_error(changeset, opts) do
|
||||
Ash.Changeset.add_error(changeset, opts)
|
||||
end
|
||||
|
||||
defp add_after_action(changeset, field, show_in_overview, required) do
|
||||
Ash.Changeset.after_action(changeset, fn _changeset, settings ->
|
||||
# Update both JSONB columns in one statement
|
||||
sql = """
|
||||
UPDATE settings
|
||||
SET
|
||||
member_field_visibility = jsonb_set(
|
||||
COALESCE(member_field_visibility, '{}'::jsonb),
|
||||
ARRAY[$1::text],
|
||||
to_jsonb($2::boolean),
|
||||
true
|
||||
),
|
||||
member_field_required = jsonb_set(
|
||||
COALESCE(member_field_required, '{}'::jsonb),
|
||||
ARRAY[$1::text],
|
||||
to_jsonb($3::boolean),
|
||||
true
|
||||
),
|
||||
updated_at = (now() AT TIME ZONE 'utc')
|
||||
WHERE id = $4
|
||||
RETURNING member_field_visibility, member_field_required
|
||||
"""
|
||||
|
||||
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 = %{
|
||||
settings
|
||||
| member_field_visibility: vis,
|
||||
member_field_required: req
|
||||
}
|
||||
|
||||
{: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)
|
||||
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
|
||||
|
|
@ -7,6 +7,8 @@ defmodule Mv.Application do
|
|||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
Mv.Vereinfacht.SyncFlash.create_table!()
|
||||
|
||||
children = [
|
||||
MvWeb.Telemetry,
|
||||
Mv.Repo,
|
||||
|
|
|
|||
15
lib/mv/authorization/checks/actor_is_system_user.ex
Normal file
15
lib/mv/authorization/checks/actor_is_system_user.ex
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
defmodule Mv.Authorization.Checks.ActorIsSystemUser do
|
||||
@moduledoc """
|
||||
Policy check: true only when the actor is the system user (e.g. system@mila.local).
|
||||
|
||||
Used to restrict internal actions (e.g. Member.set_vereinfacht_contact_id) so that
|
||||
only code paths using SystemActor can perform them, not regular admins.
|
||||
"""
|
||||
use Ash.Policy.SimpleCheck
|
||||
|
||||
@impl true
|
||||
def describe(_opts), do: "actor is the system user"
|
||||
|
||||
@impl true
|
||||
def match?(actor, _context, _opts), do: Mv.Helpers.SystemActor.system_user?(actor)
|
||||
end
|
||||
288
lib/mv/config.ex
288
lib/mv/config.ex
|
|
@ -142,4 +142,292 @@ defmodule Mv.Config do
|
|||
|> Keyword.get(key, default)
|
||||
|> parse_and_validate_integer(default)
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vereinfacht accounting software integration
|
||||
# ENV variables take priority; fallback to Settings from database.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Returns the Vereinfacht API base URL.
|
||||
|
||||
Reads from `VEREINFACHT_API_URL` env first, then from Settings.
|
||||
"""
|
||||
@spec vereinfacht_api_url() :: String.t() | nil
|
||||
def vereinfacht_api_url do
|
||||
env_or_setting("VEREINFACHT_API_URL", :vereinfacht_api_url)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the Vereinfacht API key (Bearer token).
|
||||
|
||||
Reads from `VEREINFACHT_API_KEY` env first, then from Settings.
|
||||
"""
|
||||
@spec vereinfacht_api_key() :: String.t() | nil
|
||||
def vereinfacht_api_key do
|
||||
env_or_setting("VEREINFACHT_API_KEY", :vereinfacht_api_key)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the Vereinfacht club ID for multi-tenancy.
|
||||
|
||||
Reads from `VEREINFACHT_CLUB_ID` env first, then from Settings.
|
||||
"""
|
||||
@spec vereinfacht_club_id() :: String.t() | nil
|
||||
def vereinfacht_club_id do
|
||||
env_or_setting("VEREINFACHT_CLUB_ID", :vereinfacht_club_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the Vereinfacht app base URL for contact view links (frontend, not API).
|
||||
|
||||
Reads from `VEREINFACHT_APP_URL` env first, then from Settings.
|
||||
Used to build links like https://app.verein.visuel.dev/en/admin/finances/contacts/{id}.
|
||||
If not set, derived from API URL by replacing host \"api.\" with \"app.\" when possible.
|
||||
"""
|
||||
@spec vereinfacht_app_url() :: String.t() | nil
|
||||
def vereinfacht_app_url do
|
||||
env_or_setting("VEREINFACHT_APP_URL", :vereinfacht_app_url) ||
|
||||
derive_app_url_from_api_url(vereinfacht_api_url())
|
||||
end
|
||||
|
||||
defp derive_app_url_from_api_url(nil), do: nil
|
||||
|
||||
defp derive_app_url_from_api_url(api_url) when is_binary(api_url) do
|
||||
api_url = String.trim(api_url)
|
||||
uri = URI.parse(api_url)
|
||||
host = uri.host || ""
|
||||
|
||||
if String.starts_with?(host, "api.") do
|
||||
app_host = "app." <> String.slice(host, 4..-1//1)
|
||||
scheme = uri.scheme || "https"
|
||||
"#{scheme}://#{app_host}"
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp derive_app_url_from_api_url(_), do: nil
|
||||
|
||||
@doc """
|
||||
Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set).
|
||||
"""
|
||||
@spec vereinfacht_configured?() :: boolean()
|
||||
def vereinfacht_configured? do
|
||||
present?(vereinfacht_api_url()) and present?(vereinfacht_api_key()) and
|
||||
present?(vereinfacht_club_id())
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if any Vereinfacht ENV variable is set (used to show hint in Settings UI).
|
||||
"""
|
||||
@spec vereinfacht_env_configured?() :: boolean()
|
||||
def vereinfacht_env_configured? do
|
||||
vereinfacht_api_url_env_set?() or vereinfacht_api_key_env_set?() or
|
||||
vereinfacht_club_id_env_set?()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if VEREINFACHT_API_URL is set (field is read-only in Settings).
|
||||
"""
|
||||
def vereinfacht_api_url_env_set?, do: env_set?("VEREINFACHT_API_URL")
|
||||
|
||||
@doc """
|
||||
Returns true if VEREINFACHT_API_KEY is set (field is read-only in Settings).
|
||||
"""
|
||||
def vereinfacht_api_key_env_set?, do: env_set?("VEREINFACHT_API_KEY")
|
||||
|
||||
@doc """
|
||||
Returns true if VEREINFACHT_CLUB_ID is set (field is read-only in Settings).
|
||||
"""
|
||||
def vereinfacht_club_id_env_set?, do: env_set?("VEREINFACHT_CLUB_ID")
|
||||
|
||||
@doc """
|
||||
Returns true if VEREINFACHT_APP_URL is set (field is read-only in Settings).
|
||||
"""
|
||||
def vereinfacht_app_url_env_set?, do: env_set?("VEREINFACHT_APP_URL")
|
||||
|
||||
defp env_set?(key) do
|
||||
case System.get_env(key) do
|
||||
nil -> false
|
||||
v when is_binary(v) -> String.trim(v) != ""
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp env_or_setting(env_key, setting_key) do
|
||||
case System.get_env(env_key) do
|
||||
nil -> get_vereinfacht_from_settings(setting_key)
|
||||
value -> trim_nil(value)
|
||||
end
|
||||
end
|
||||
|
||||
defp env_or_setting_bool(env_key, setting_key) do
|
||||
case System.get_env(env_key) do
|
||||
nil ->
|
||||
get_from_settings_bool(setting_key)
|
||||
|
||||
value when is_binary(value) ->
|
||||
v = String.trim(value) |> String.downcase()
|
||||
v in ["true", "1", "yes"]
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
defp get_vereinfacht_from_settings(key) do
|
||||
get_from_settings(key)
|
||||
end
|
||||
|
||||
defp get_from_settings(key) do
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, settings} -> settings |> Map.get(key) |> trim_nil()
|
||||
{:error, _} -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp get_from_settings_bool(key) do
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, settings} ->
|
||||
case Map.get(settings, key) do
|
||||
true -> true
|
||||
_ -> false
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
defp trim_nil(nil), do: nil
|
||||
|
||||
defp trim_nil(s) when is_binary(s) do
|
||||
t = String.trim(s)
|
||||
if t == "", do: nil, else: t
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the URL to view a finance contact in the Vereinfacht app (frontend).
|
||||
|
||||
Uses the configured app base URL (or derived from API URL) and appends
|
||||
/en/admin/finances/contacts/{id}. Returns nil if no app URL can be determined.
|
||||
"""
|
||||
@spec vereinfacht_contact_view_url(String.t()) :: String.t() | nil
|
||||
def vereinfacht_contact_view_url(contact_id) when is_binary(contact_id) do
|
||||
base = vereinfacht_app_url()
|
||||
|
||||
if present?(base) do
|
||||
base
|
||||
|> String.trim_trailing("/")
|
||||
|> then(&"#{&1}/en/admin/finances/contacts/#{contact_id}")
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp present?(nil), do: false
|
||||
defp present?(s) when is_binary(s), do: String.trim(s) != ""
|
||||
defp present?(_), do: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OIDC authentication
|
||||
# ENV variables take priority; fallback to Settings from database.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Returns the OIDC client ID. ENV first, then Settings.
|
||||
"""
|
||||
@spec oidc_client_id() :: String.t() | nil
|
||||
def oidc_client_id do
|
||||
env_or_setting("OIDC_CLIENT_ID", :oidc_client_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the OIDC provider base URL. ENV first, then Settings.
|
||||
"""
|
||||
@spec oidc_base_url() :: String.t() | nil
|
||||
def oidc_base_url do
|
||||
env_or_setting("OIDC_BASE_URL", :oidc_base_url)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the OIDC redirect URI. ENV first, then Settings.
|
||||
"""
|
||||
@spec oidc_redirect_uri() :: String.t() | nil
|
||||
def oidc_redirect_uri do
|
||||
env_or_setting("OIDC_REDIRECT_URI", :oidc_redirect_uri)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the OIDC client secret. ENV first, then Settings.
|
||||
"""
|
||||
@spec oidc_client_secret() :: String.t() | nil
|
||||
def oidc_client_secret do
|
||||
env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the OIDC admin group name (for role sync). ENV first, then Settings.
|
||||
"""
|
||||
@spec oidc_admin_group_name() :: String.t() | nil
|
||||
def oidc_admin_group_name do
|
||||
env_or_setting("OIDC_ADMIN_GROUP_NAME", :oidc_admin_group_name)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the OIDC groups claim name (default "groups"). ENV first, then Settings.
|
||||
"""
|
||||
@spec oidc_groups_claim() :: String.t() | nil
|
||||
def oidc_groups_claim do
|
||||
case env_or_setting("OIDC_GROUPS_CLAIM", :oidc_groups_claim) do
|
||||
nil -> "groups"
|
||||
v -> v
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if any OIDC ENV variable is set (used to show hint in Settings UI).
|
||||
"""
|
||||
@spec oidc_env_configured?() :: boolean()
|
||||
def oidc_env_configured? do
|
||||
oidc_client_id_env_set?() or oidc_base_url_env_set?() or
|
||||
oidc_redirect_uri_env_set?() or oidc_client_secret_env_set?() or
|
||||
oidc_admin_group_name_env_set?() or oidc_groups_claim_env_set?() or
|
||||
oidc_only_env_set?()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true when OIDC is configured and can be used for sign-in (client ID, base URL,
|
||||
redirect URI, and client secret must be set). Used to show or hide the Single Sign-On button on the
|
||||
sign-in page. Without client secret, the OIDC flow fails with MissingSecret; without redirect_uri,
|
||||
the OIDC Plug crashes with URI.new(nil).
|
||||
"""
|
||||
@spec oidc_configured?() :: boolean()
|
||||
def oidc_configured? do
|
||||
id = oidc_client_id()
|
||||
base = oidc_base_url()
|
||||
secret = oidc_client_secret()
|
||||
redirect = oidc_redirect_uri()
|
||||
present = &(is_binary(&1) and String.trim(&1) != "")
|
||||
present.(id) and present.(base) and present.(secret) and present.(redirect)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true when only OIDC sign-in should be shown (password login hidden).
|
||||
ENV OIDC_ONLY first (true/1/yes vs false/0/no), then Settings.oidc_only.
|
||||
Only has effect when OIDC is configured; when false or OIDC not configured, both password and OIDC are shown as usual.
|
||||
"""
|
||||
@spec oidc_only?() :: boolean()
|
||||
def oidc_only? do
|
||||
env_or_setting_bool("OIDC_ONLY", :oidc_only)
|
||||
end
|
||||
|
||||
def oidc_client_id_env_set?, do: env_set?("OIDC_CLIENT_ID")
|
||||
def oidc_base_url_env_set?, do: env_set?("OIDC_BASE_URL")
|
||||
def oidc_redirect_uri_env_set?, do: env_set?("OIDC_REDIRECT_URI")
|
||||
def oidc_client_secret_env_set?, do: env_set?("OIDC_CLIENT_SECRET")
|
||||
def oidc_admin_group_name_env_set?, do: env_set?("OIDC_ADMIN_GROUP_NAME")
|
||||
def oidc_groups_claim_env_set?, do: env_set?("OIDC_GROUPS_CLAIM")
|
||||
def oidc_only_env_set?, do: env_set?("OIDC_ONLY")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ defmodule Mv.Constants do
|
|||
:join_date,
|
||||
:exit_date,
|
||||
:notes,
|
||||
:country,
|
||||
:city,
|
||||
:street,
|
||||
:house_number,
|
||||
|
|
@ -27,8 +28,26 @@ defmodule Mv.Constants do
|
|||
|
||||
@email_validator_checks [:html_input, :pow]
|
||||
|
||||
# Member fields that are required when Vereinfacht integration is active (contact sync)
|
||||
@vereinfacht_required_member_fields [:first_name, :last_name, :street, :postal_code, :city]
|
||||
|
||||
def member_fields, do: @member_fields
|
||||
|
||||
@doc """
|
||||
Returns member fields that are always required when Vereinfacht integration is configured.
|
||||
|
||||
Used for validation, member form required indicators, and settings UI (checkbox disabled).
|
||||
"""
|
||||
def vereinfacht_required_member_fields, do: @vereinfacht_required_member_fields
|
||||
|
||||
@doc """
|
||||
Returns whether the given member field is required by Vereinfacht when integration is active.
|
||||
"""
|
||||
def vereinfacht_required_field?(field) when is_atom(field),
|
||||
do: field in @vereinfacht_required_member_fields
|
||||
|
||||
def vereinfacht_required_field?(_), do: false
|
||||
|
||||
@doc """
|
||||
Returns the prefix used for custom field keys in field visibility maps.
|
||||
|
||||
|
|
|
|||
|
|
@ -17,15 +17,24 @@ defmodule Mv.Membership.Import.HeaderMapper do
|
|||
|
||||
## Member Field Mapping
|
||||
|
||||
Maps CSV headers to canonical member fields:
|
||||
- `email` (required)
|
||||
- `first_name` (optional)
|
||||
- `last_name` (optional)
|
||||
- `street` (optional)
|
||||
- `postal_code` (optional)
|
||||
- `city` (optional)
|
||||
Maps CSV headers to canonical member fields (same as `Mv.Constants.member_fields()` for
|
||||
importable attributes). All DB-backed member attributes can be imported.
|
||||
|
||||
Supports both English and German variants (e.g., "Email" / "E-Mail", "First Name" / "Vorname").
|
||||
- `email` (required)
|
||||
- `first_name`, `last_name` (optional)
|
||||
- `join_date`, `exit_date` (optional, ISO-8601 date)
|
||||
- `notes` (optional)
|
||||
- `country`, `city`, `street`, `house_number`, `postal_code` (optional)
|
||||
- `membership_fee_start_date` (optional, ISO-8601 date)
|
||||
|
||||
Supports English and German header variants (e.g. "Email" / "E-Mail", "Join Date" / "Beitrittsdatum").
|
||||
|
||||
## Fields not supported for import
|
||||
|
||||
- **membership_fee_status** – Computed (calculation from membership fee cycles). Not stored;
|
||||
cannot be set via CSV. Export can include it.
|
||||
- **groups** – Many-to-many relationship (through member_groups). Import would require
|
||||
resolving group names/slugs to IDs and creating associations; not in current import scope.
|
||||
|
||||
## Custom Field Detection
|
||||
|
||||
|
|
@ -75,11 +84,37 @@ defmodule Mv.Membership.Import.HeaderMapper do
|
|||
"nachname",
|
||||
"familienname"
|
||||
],
|
||||
join_date: [
|
||||
"join date",
|
||||
"join_date",
|
||||
"beitrittsdatum",
|
||||
"beitritts-datum"
|
||||
],
|
||||
exit_date: [
|
||||
"exit date",
|
||||
"exit_date",
|
||||
"austrittsdatum",
|
||||
"austritts-datum"
|
||||
],
|
||||
notes: [
|
||||
"notes",
|
||||
"notizen",
|
||||
"bemerkungen"
|
||||
],
|
||||
street: [
|
||||
"street",
|
||||
"address",
|
||||
"strasse"
|
||||
],
|
||||
house_number: [
|
||||
"house number",
|
||||
"house_number",
|
||||
"house no",
|
||||
"hausnummer",
|
||||
"nr",
|
||||
"nr.",
|
||||
"nummer"
|
||||
],
|
||||
postal_code: [
|
||||
"postal code",
|
||||
"postal_code",
|
||||
|
|
@ -93,6 +128,18 @@ defmodule Mv.Membership.Import.HeaderMapper do
|
|||
"town",
|
||||
"stadt",
|
||||
"ort"
|
||||
],
|
||||
country: [
|
||||
"country",
|
||||
"land",
|
||||
"staat"
|
||||
],
|
||||
membership_fee_start_date: [
|
||||
"membership fee start date",
|
||||
"membership_fee_start_date",
|
||||
"fee start",
|
||||
"beitragsbeginn",
|
||||
"beitrags-beginn"
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -549,9 +549,12 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
line_number,
|
||||
actor
|
||||
) do
|
||||
# Convert empty strings to nil for date fields so Ash accepts them
|
||||
member_attrs = sanitize_date_fields(trimmed_member_attrs)
|
||||
|
||||
# Create member with custom field values
|
||||
member_attrs_with_cf =
|
||||
trimmed_member_attrs
|
||||
member_attrs
|
||||
|> Map.put(:custom_field_values, custom_field_values)
|
||||
|
||||
# Only include custom_field_values if not empty
|
||||
|
|
@ -793,6 +796,23 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
end)
|
||||
end
|
||||
|
||||
# Converts empty strings to nil for date fields so Ash can accept them
|
||||
@date_fields [:join_date, :exit_date, :membership_fee_start_date]
|
||||
|
||||
defp sanitize_date_fields(attrs) when is_map(attrs) do
|
||||
Enum.reduce(@date_fields, attrs, fn field, acc ->
|
||||
put_date_field(acc, field, Map.get(acc, field))
|
||||
end)
|
||||
end
|
||||
|
||||
defp put_date_field(acc, field, ""), do: Map.put(acc, field, nil)
|
||||
|
||||
defp put_date_field(acc, field, val) when is_binary(val) do
|
||||
if String.trim(val) == "", do: Map.put(acc, field, nil), else: acc
|
||||
end
|
||||
|
||||
defp put_date_field(acc, _field, _), do: acc
|
||||
|
||||
# Formats Ash errors into MemberCSV.Error structs
|
||||
defp format_ash_error(%Ash.Error.Invalid{errors: errors}, line_number, email) do
|
||||
# Try to find email-related errors first (for better error messages)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ defmodule Mv.Membership.MemberExport do
|
|||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||
|
||||
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
||||
["membership_fee_status"]
|
||||
["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()
|
||||
|
|
@ -323,10 +323,14 @@ defmodule Mv.Membership.MemberExport do
|
|||
|> Enum.filter(&(&1 in @domain_member_field_strings))
|
||||
|> order_member_fields_like_table()
|
||||
|
||||
# final member_fields list (used for column specs order): table order + computed inserted
|
||||
# Separate groups from other fields (groups is handled as a special field, not a member field)
|
||||
groups_field = if "groups" in member_fields, do: ["groups"], else: []
|
||||
|
||||
# final member_fields list (used for column specs order): table order + fee type + computed + groups
|
||||
ordered_member_fields =
|
||||
selectable_member_fields
|
||||
|> insert_computed_fields_like_table(computed_fields)
|
||||
|> insert_fee_type_and_computed_fields_like_table(computed_fields, member_fields)
|
||||
|> then(fn fields -> fields ++ groups_field end)
|
||||
|
||||
%{
|
||||
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
|
||||
|
|
@ -416,27 +420,52 @@ defmodule Mv.Membership.MemberExport do
|
|||
table_order |> Enum.filter(&(&1 in fields))
|
||||
end
|
||||
|
||||
defp insert_computed_fields_like_table(db_fields_ordered, computed_fields) do
|
||||
# Insert membership_fee_status right after membership_fee_start_date (if both selected),
|
||||
# otherwise append at the end of DB fields.
|
||||
defp insert_fee_type_and_computed_fields_like_table(
|
||||
db_fields_ordered,
|
||||
computed_fields,
|
||||
member_fields
|
||||
) do
|
||||
computed_fields = computed_fields || []
|
||||
member_fields = member_fields || []
|
||||
|
||||
db_with_insert =
|
||||
Enum.flat_map(db_fields_ordered, fn f ->
|
||||
if f == @computed_insert_after and "membership_fee_status" in computed_fields do
|
||||
[f, "membership_fee_status"]
|
||||
else
|
||||
[f]
|
||||
end
|
||||
expand_field_with_computed(f, member_fields, computed_fields)
|
||||
end)
|
||||
|
||||
remaining =
|
||||
computed_fields
|
||||
|> Enum.reject(&(&1 in db_with_insert))
|
||||
# If fee type is visible but start_date was not in the list, it won't be in db_with_insert
|
||||
db_with_insert =
|
||||
if "membership_fee_type" in member_fields and "membership_fee_type" not in db_with_insert do
|
||||
db_with_insert ++ ["membership_fee_type"]
|
||||
else
|
||||
db_with_insert
|
||||
end
|
||||
|
||||
remaining = Enum.reject(computed_fields, &(&1 in db_with_insert))
|
||||
db_with_insert ++ remaining
|
||||
end
|
||||
|
||||
# Insert membership_fee_type and membership_fee_status after membership_fee_start_date (table order).
|
||||
defp expand_field_with_computed(f, member_fields, computed_fields) do
|
||||
if f == @computed_insert_after do
|
||||
extra = []
|
||||
|
||||
extra =
|
||||
if "membership_fee_type" in member_fields,
|
||||
do: extra ++ ["membership_fee_type"],
|
||||
else: extra
|
||||
|
||||
extra =
|
||||
if "membership_fee_status" in computed_fields,
|
||||
do: extra ++ ["membership_fee_status"],
|
||||
else: extra
|
||||
|
||||
[f] ++ extra
|
||||
else
|
||||
[f]
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_computed_fields(fields) when is_list(fields) do
|
||||
fields
|
||||
|> Enum.filter(&is_binary/1)
|
||||
|
|
|
|||
|
|
@ -132,12 +132,20 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
parsed.computed_fields != [] or
|
||||
"membership_fee_status" in parsed.member_fields
|
||||
|
||||
need_groups = "groups" in parsed.member_fields
|
||||
|
||||
need_membership_fee_type =
|
||||
"membership_fee_type" in parsed.member_fields or
|
||||
parsed.sort_field == "membership_fee_type"
|
||||
|
||||
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)
|
||||
|> maybe_load_groups(need_groups)
|
||||
|> maybe_load_membership_fee_type(need_membership_fee_type)
|
||||
|
||||
query =
|
||||
if parsed.selected_ids != [] do
|
||||
|
|
@ -193,8 +201,10 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
defp sort_members_in_memory(members, field, order) when is_binary(field) do
|
||||
field_atom = String.to_existing_atom(field)
|
||||
|
||||
if field_atom in Mv.Constants.member_fields() do
|
||||
sort_by_field(members, field_atom, order)
|
||||
if field_atom in Mv.Constants.member_fields() or field_atom == :membership_fee_type do
|
||||
key_fn = sort_key_fn_for_field(field_atom)
|
||||
compare_fn = build_compare_fn(order)
|
||||
Enum.sort_by(members, key_fn, compare_fn)
|
||||
else
|
||||
members
|
||||
end
|
||||
|
|
@ -204,13 +214,17 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
|
||||
defp sort_members_in_memory(members, _field, _order), do: members
|
||||
|
||||
defp sort_by_field(members, field_atom, order) do
|
||||
key_fn = fn member -> Map.get(member, field_atom) end
|
||||
compare_fn = build_compare_fn(order)
|
||||
|
||||
Enum.sort_by(members, key_fn, compare_fn)
|
||||
defp sort_key_fn_for_field(:membership_fee_type) do
|
||||
fn member ->
|
||||
case Map.get(member, :membership_fee_type) do
|
||||
nil -> nil
|
||||
rel -> Map.get(rel, :name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp sort_key_fn_for_field(field_atom), do: fn member -> Map.get(member, field_atom) end
|
||||
|
||||
defp build_compare_fn("asc"), do: fn a, b -> a <= b end
|
||||
defp build_compare_fn("desc"), do: fn a, b -> b <= a end
|
||||
defp build_compare_fn(_), do: fn _a, _b -> true end
|
||||
|
|
@ -241,30 +255,65 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
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
|
||||
cond do
|
||||
field == "groups" -> {query, true}
|
||||
field == "membership_fee_type" -> apply_fee_type_sort(query, order)
|
||||
custom_field_sort?(field) -> {query, true}
|
||||
true -> apply_standard_member_sort(query, field, order)
|
||||
end
|
||||
rescue
|
||||
ArgumentError -> {query, false}
|
||||
end
|
||||
|
||||
defp apply_fee_type_sort(query, order) do
|
||||
order_atom = if order == "desc", do: :desc, else: :asc
|
||||
{Ash.Query.sort(query, [{"membership_fee_type.name", order_atom}]), false}
|
||||
end
|
||||
|
||||
defp apply_standard_member_sort(query, field, order) do
|
||||
field_atom = String.to_existing_atom(field)
|
||||
|
||||
sortable =
|
||||
field_atom in (Mv.Constants.member_fields() -- [:notes]) or
|
||||
field_atom == :membership_fee_type
|
||||
|
||||
if sortable do
|
||||
order_atom = if order == "desc", do: :desc, else: :asc
|
||||
|
||||
sort_field =
|
||||
if field_atom == :membership_fee_type,
|
||||
do: {"membership_fee_type.name", order_atom},
|
||||
else: {field_atom, order_atom}
|
||||
|
||||
{Ash.Query.sort(query, [sort_field]), false}
|
||||
else
|
||||
{query, false}
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
if field == "groups" do
|
||||
sort_members_by_groups_export(members, order)
|
||||
else
|
||||
sort_by_custom_field_value(members, field, order, custom_fields)
|
||||
end
|
||||
end
|
||||
|
||||
defp sort_by_custom_field_value(members, field, order, custom_fields) 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
|
||||
if is_nil(custom_field) do
|
||||
members
|
||||
else
|
||||
sort_members_with_custom_field(members, custom_field, order)
|
||||
end
|
||||
end
|
||||
|
||||
defp sort_members_with_custom_field(members, custom_field, order) do
|
||||
key_fn = fn member ->
|
||||
cfv = find_cfv(member, custom_field)
|
||||
raw = if cfv, do: cfv.value, else: nil
|
||||
|
|
@ -277,6 +326,26 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
|> Enum.map(fn {m, _} -> m end)
|
||||
end
|
||||
|
||||
defp sort_members_by_groups_export(members, order) do
|
||||
# Members with groups first, then by first group name alphabetically (min = first by sort order)
|
||||
# Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2
|
||||
first_group_name = fn member ->
|
||||
(member.groups || [])
|
||||
|> Enum.map(& &1.name)
|
||||
|> Enum.min(fn -> nil end)
|
||||
end
|
||||
|
||||
members
|
||||
|> Enum.sort_by(fn member ->
|
||||
name = first_group_name.(member)
|
||||
# Nil (no groups) sorts last in asc, first in desc
|
||||
{name == nil, name || ""}
|
||||
end)
|
||||
|> then(fn list ->
|
||||
if order == "desc", do: Enum.reverse(list), else: list
|
||||
end)
|
||||
end
|
||||
|
||||
defp find_cfv(member, custom_field) do
|
||||
(member.custom_field_values || [])
|
||||
|> Enum.find(fn cfv ->
|
||||
|
|
@ -294,6 +363,19 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
MembershipFeeStatus.load_cycles_for_members(query, show_current)
|
||||
end
|
||||
|
||||
defp maybe_load_groups(query, false), do: query
|
||||
|
||||
defp maybe_load_groups(query, true) do
|
||||
# Load groups with id and name only (for export formatting)
|
||||
Ash.Query.load(query, groups: [:id, :name])
|
||||
end
|
||||
|
||||
defp maybe_load_membership_fee_type(query, false), do: query
|
||||
|
||||
defp maybe_load_membership_fee_type(query, true) do
|
||||
Ash.Query.load(query, membership_fee_type: [:id, :name])
|
||||
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
|
||||
|
|
@ -343,6 +425,32 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
}
|
||||
end)
|
||||
|
||||
membership_fee_type_col =
|
||||
if "membership_fee_type" in parsed.member_fields do
|
||||
[
|
||||
%{
|
||||
key: :membership_fee_type,
|
||||
kind: :membership_fee_type,
|
||||
label: label_fn.(:membership_fee_type)
|
||||
}
|
||||
]
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
groups_col =
|
||||
if "groups" in parsed.member_fields do
|
||||
[
|
||||
%{
|
||||
key: :groups,
|
||||
kind: :groups,
|
||||
label: label_fn.(:groups)
|
||||
}
|
||||
]
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
custom_cols =
|
||||
parsed.custom_field_ids
|
||||
|> Enum.map(fn id ->
|
||||
|
|
@ -361,7 +469,8 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
member_cols ++ computed_cols ++ custom_cols
|
||||
# Table order: ... membership_fee_start_date, membership_fee_type, membership_fee_status, groups, custom
|
||||
member_cols ++ membership_fee_type_col ++ computed_cols ++ groups_col ++ custom_cols
|
||||
end
|
||||
|
||||
defp build_rows(members, columns, custom_fields_by_id) do
|
||||
|
|
@ -391,6 +500,22 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
if is_binary(value), do: value, else: ""
|
||||
end
|
||||
|
||||
defp cell_value(
|
||||
member,
|
||||
%{kind: :membership_fee_type, key: :membership_fee_type},
|
||||
_custom_fields_by_id
|
||||
) do
|
||||
case Map.get(member, :membership_fee_type) do
|
||||
%{name: name} when is_binary(name) -> name
|
||||
_ -> ""
|
||||
end
|
||||
end
|
||||
|
||||
defp cell_value(member, %{kind: :groups, key: :groups}, _custom_fields_by_id) do
|
||||
groups = Map.get(member, :groups) || []
|
||||
format_groups(groups)
|
||||
end
|
||||
|
||||
defp key_to_atom(k) when is_atom(k), do: k
|
||||
|
||||
defp key_to_atom(k) when is_binary(k) do
|
||||
|
|
@ -424,6 +549,15 @@ defmodule Mv.Membership.MemberExport.Build do
|
|||
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
|
||||
defp format_member_value(value), do: to_string(value)
|
||||
|
||||
defp format_groups([]), do: ""
|
||||
|
||||
defp format_groups(groups) when is_list(groups) do
|
||||
groups
|
||||
|> Enum.map(fn group -> Map.get(group, :name) || "" end)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|> Enum.join(", ")
|
||||
end
|
||||
|
||||
defp build_meta(members) do
|
||||
%{
|
||||
generated_at: DateTime.utc_now() |> DateTime.to_iso8601(),
|
||||
|
|
|
|||
|
|
@ -59,6 +59,18 @@ defmodule Mv.Membership.MembersCSV do
|
|||
if is_binary(value), do: value, else: ""
|
||||
end
|
||||
|
||||
defp cell_value(member, %{kind: :membership_fee_type, key: :membership_fee_type}) do
|
||||
case Map.get(member, :membership_fee_type) do
|
||||
%{name: name} when is_binary(name) -> name
|
||||
_ -> ""
|
||||
end
|
||||
end
|
||||
|
||||
defp cell_value(member, %{kind: :groups, key: :groups}) do
|
||||
groups = Map.get(member, :groups) || []
|
||||
format_groups(groups)
|
||||
end
|
||||
|
||||
defp key_to_atom(k) when is_atom(k), do: k
|
||||
|
||||
defp key_to_atom(k) when is_binary(k) do
|
||||
|
|
@ -97,4 +109,13 @@ defmodule Mv.Membership.MembersCSV do
|
|||
defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
|
||||
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
|
||||
defp format_member_value(value), do: to_string(value)
|
||||
|
||||
defp format_groups([]), do: ""
|
||||
|
||||
defp format_groups(groups) when is_list(groups) do
|
||||
groups
|
||||
|> Enum.map(fn group -> Map.get(group, :name) || "" end)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|> Enum.join(", ")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ defmodule Mv.OidcRoleSync do
|
|||
@moduledoc """
|
||||
Syncs user role from OIDC user_info (e.g. groups claim → Admin role).
|
||||
|
||||
Used after OIDC registration (register_with_rauthy) and on sign-in so that
|
||||
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).
|
||||
|
||||
|
|
|
|||
|
|
@ -2,23 +2,19 @@ defmodule Mv.OidcRoleSyncConfig do
|
|||
@moduledoc """
|
||||
Runtime configuration for OIDC group → role sync (e.g. admin group → Admin role).
|
||||
|
||||
Reads from Application config `:mv, :oidc_role_sync`:
|
||||
- `:admin_group_name` – OIDC group name that maps to Admin role (optional; when nil, no sync).
|
||||
- `:groups_claim` – JWT/user_info claim name for groups (default: `"groups"`).
|
||||
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 in production: OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM (see config/runtime.exs).
|
||||
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
|
||||
get(:admin_group_name)
|
||||
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
|
||||
get(:groups_claim) || "groups"
|
||||
end
|
||||
|
||||
defp get(key) do
|
||||
Application.get_env(:mv, :oidc_role_sync, []) |> Keyword.get(key)
|
||||
Mv.Config.oidc_groups_claim() || "groups"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,59 +7,66 @@ defmodule Mv.Secrets do
|
|||
particularly for OIDC (Rauthy) authentication.
|
||||
|
||||
## Configuration Source
|
||||
Secrets are read from the `:rauthy` key in the application configuration,
|
||||
which is typically set in `config/runtime.exs` from environment variables:
|
||||
- `OIDC_CLIENT_ID`
|
||||
- `OIDC_CLIENT_SECRET`
|
||||
- `OIDC_BASE_URL`
|
||||
- `OIDC_REDIRECT_URI`
|
||||
Secrets are read via `Mv.Config` which prefers environment variables and
|
||||
falls back to Settings from the database:
|
||||
- OIDC_CLIENT_ID / settings.oidc_client_id
|
||||
- OIDC_CLIENT_SECRET / settings.oidc_client_secret
|
||||
- OIDC_BASE_URL / settings.oidc_base_url
|
||||
- OIDC_REDIRECT_URI / settings.oidc_redirect_uri
|
||||
|
||||
## Usage
|
||||
This module is automatically called by AshAuthentication when resolving
|
||||
secrets for the User resource's OIDC strategy.
|
||||
When a value is nil, returns `{:error, MissingSecret}` so that AshAuthentication
|
||||
does not crash (e.g. URI.new(nil)) and can redirect to sign-in with an error.
|
||||
"""
|
||||
use AshAuthentication.Secret
|
||||
|
||||
alias AshAuthentication.Errors.MissingSecret
|
||||
|
||||
def secret_for(
|
||||
[:authentication, :strategies, :rauthy, :client_id],
|
||||
Mv.Accounts.User,
|
||||
[:authentication, :strategies, :oidc, :client_id],
|
||||
resource,
|
||||
_opts,
|
||||
_meth
|
||||
) do
|
||||
get_config(:client_id)
|
||||
secret_or_error(Mv.Config.oidc_client_id(), resource, :client_id)
|
||||
end
|
||||
|
||||
def secret_for(
|
||||
[:authentication, :strategies, :rauthy, :redirect_uri],
|
||||
Mv.Accounts.User,
|
||||
[:authentication, :strategies, :oidc, :redirect_uri],
|
||||
resource,
|
||||
_opts,
|
||||
_meth
|
||||
) do
|
||||
get_config(:redirect_uri)
|
||||
secret_or_error(Mv.Config.oidc_redirect_uri(), resource, :redirect_uri)
|
||||
end
|
||||
|
||||
def secret_for(
|
||||
[:authentication, :strategies, :rauthy, :client_secret],
|
||||
Mv.Accounts.User,
|
||||
[:authentication, :strategies, :oidc, :client_secret],
|
||||
resource,
|
||||
_opts,
|
||||
_meth
|
||||
) do
|
||||
get_config(:client_secret)
|
||||
secret_or_error(Mv.Config.oidc_client_secret(), resource, :client_secret)
|
||||
end
|
||||
|
||||
def secret_for(
|
||||
[:authentication, :strategies, :rauthy, :base_url],
|
||||
Mv.Accounts.User,
|
||||
[:authentication, :strategies, :oidc, :base_url],
|
||||
resource,
|
||||
_opts,
|
||||
_meth
|
||||
) do
|
||||
get_config(:base_url)
|
||||
secret_or_error(Mv.Config.oidc_base_url(), resource, :base_url)
|
||||
end
|
||||
|
||||
defp get_config(key) do
|
||||
:mv
|
||||
|> Application.fetch_env!(:rauthy)
|
||||
|> Keyword.fetch!(key)
|
||||
|> then(&{:ok, &1})
|
||||
defp secret_or_error(nil, resource, key) do
|
||||
path = [:authentication, :strategies, :oidc, key]
|
||||
{:error, MissingSecret.exception(path: path, resource: resource)}
|
||||
end
|
||||
|
||||
defp secret_or_error(value, resource, key) when is_binary(value) do
|
||||
if String.trim(value) == "" do
|
||||
secret_or_error(nil, resource, key)
|
||||
else
|
||||
{:ok, value}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
91
lib/mv/vereinfacht/changes/sync_contact.ex
Normal file
91
lib/mv/vereinfacht/changes/sync_contact.ex
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
defmodule Mv.Vereinfacht.Changes.SyncContact do
|
||||
@moduledoc """
|
||||
Syncs a member to Vereinfacht as a finance contact after create/update.
|
||||
|
||||
- If the member has no `vereinfacht_contact_id`, creates a contact via API and saves the ID.
|
||||
- If the member already has an ID, updates the contact via API.
|
||||
Runs in `after_transaction` so the member is persisted first. API failures are logged
|
||||
but do not block the member operation. Requires Vereinfacht to be configured
|
||||
(Mv.Config.vereinfacht_configured?/0).
|
||||
|
||||
Only runs when relevant data changed: on create always; on update only when
|
||||
first_name, last_name, email, street, house_number, postal_code, or city changed,
|
||||
or when the member has no vereinfacht_contact_id yet (to avoid unnecessary API calls).
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
require Logger
|
||||
|
||||
@synced_attributes [
|
||||
:first_name,
|
||||
:last_name,
|
||||
:email,
|
||||
:street,
|
||||
:house_number,
|
||||
:postal_code,
|
||||
:city
|
||||
]
|
||||
|
||||
@impl true
|
||||
def change(changeset, _opts, _context) do
|
||||
if Mv.Config.vereinfacht_configured?() and sync_relevant?(changeset) do
|
||||
Ash.Changeset.after_transaction(changeset, &sync_after_transaction/2)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp sync_relevant?(changeset) do
|
||||
case changeset.action_type do
|
||||
:create -> true
|
||||
:update -> relevant_update?(changeset)
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp relevant_update?(changeset) do
|
||||
any_synced_attr_changed? =
|
||||
Enum.any?(@synced_attributes, &Ash.Changeset.changing_attribute?(changeset, &1))
|
||||
|
||||
record = changeset.data
|
||||
no_contact_id_yet? = record && blank_contact_id?(record.vereinfacht_contact_id)
|
||||
|
||||
any_synced_attr_changed? or no_contact_id_yet?
|
||||
end
|
||||
|
||||
defp blank_contact_id?(nil), do: true
|
||||
defp blank_contact_id?(""), do: true
|
||||
defp blank_contact_id?(s) when is_binary(s), do: String.trim(s) == ""
|
||||
defp blank_contact_id?(_), do: false
|
||||
|
||||
# Ash calls after_transaction with (changeset, result) only - 2 args.
|
||||
defp sync_after_transaction(_changeset, {:ok, member}) do
|
||||
case Mv.Vereinfacht.sync_member(member) do
|
||||
:ok ->
|
||||
Mv.Vereinfacht.SyncFlash.store(to_string(member.id), :ok, "Synced to Vereinfacht.")
|
||||
{:ok, member}
|
||||
|
||||
{:ok, member_updated} ->
|
||||
Mv.Vereinfacht.SyncFlash.store(
|
||||
to_string(member_updated.id),
|
||||
:ok,
|
||||
"Synced to Vereinfacht."
|
||||
)
|
||||
|
||||
{:ok, member_updated}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Vereinfacht sync failed for member #{member.id}: #{inspect(reason)}")
|
||||
|
||||
Mv.Vereinfacht.SyncFlash.store(
|
||||
to_string(member.id),
|
||||
:warning,
|
||||
Mv.Vereinfacht.format_error(reason)
|
||||
)
|
||||
|
||||
{:ok, member}
|
||||
end
|
||||
end
|
||||
|
||||
defp sync_after_transaction(_changeset, error), do: error
|
||||
end
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do
|
||||
@moduledoc """
|
||||
Syncs the linked Member to Vereinfacht after a User action that may have updated
|
||||
the member's email via Ecto (e.g. User email change → SyncUserEmailToMember).
|
||||
|
||||
Attach to any User action that uses SyncUserEmailToMember. After the transaction
|
||||
commits, if the user has a linked member and Vereinfacht is configured, syncs
|
||||
that member to the API. Failures are logged but do not affect the User result.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
require Logger
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.Membership
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Helpers
|
||||
|
||||
@impl true
|
||||
def change(changeset, _opts, _context) do
|
||||
if Mv.Config.vereinfacht_configured?() and relevant_change?(changeset) do
|
||||
Ash.Changeset.after_transaction(changeset, &sync_linked_member_after_transaction/2)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
# Only sync when something that affects the linked member's data actually changed
|
||||
# (email sync or member link), to avoid unnecessary API calls on every user update.
|
||||
defp relevant_change?(changeset) do
|
||||
Ash.Changeset.changing_attribute?(changeset, :email) or
|
||||
Ash.Changeset.changing_relationship?(changeset, :member)
|
||||
end
|
||||
|
||||
defp sync_linked_member_after_transaction(_changeset, {:ok, user}) do
|
||||
case load_linked_member(user) do
|
||||
nil ->
|
||||
{:ok, user}
|
||||
|
||||
member ->
|
||||
case Mv.Vereinfacht.sync_member(member) do
|
||||
:ok ->
|
||||
{:ok, user}
|
||||
|
||||
{:ok, _} ->
|
||||
{:ok, user}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"Vereinfacht sync failed for member #{member.id} (linked to user #{user.id}): #{inspect(reason)}"
|
||||
)
|
||||
|
||||
{:ok, user}
|
||||
end
|
||||
end
|
||||
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
|
||||
423
lib/mv/vereinfacht/client.ex
Normal file
423
lib/mv/vereinfacht/client.ex
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
defmodule Mv.Vereinfacht.Client do
|
||||
@moduledoc """
|
||||
HTTP client for the Vereinfacht accounting software JSON:API.
|
||||
|
||||
Creates and updates finance contacts. Uses Bearer token authentication and
|
||||
requires club ID for multi-tenancy. Configuration via ENV or Settings
|
||||
(see Mv.Config).
|
||||
"""
|
||||
require Logger
|
||||
|
||||
@content_type "application/vnd.api+json"
|
||||
|
||||
@doc """
|
||||
Tests the connection to the Vereinfacht API with the given credentials.
|
||||
|
||||
Makes a lightweight `GET /finance-contacts?page[size]=1` request to verify
|
||||
that the API URL, API key, and club ID are valid and reachable.
|
||||
|
||||
## Returns
|
||||
- `{:ok, :connected}` – credentials are valid (HTTP 200)
|
||||
- `{:error, :not_configured}` – any parameter is nil or blank
|
||||
- `{:error, {:http, status, message}}` – API returned an error (e.g. 401, 403)
|
||||
- `{:error, {:request_failed, reason}}` – network/transport error
|
||||
|
||||
## Examples
|
||||
|
||||
iex> test_connection("https://api.example.com/api/v1", "token", "2")
|
||||
{:ok, :connected}
|
||||
|
||||
iex> test_connection(nil, "token", "2")
|
||||
{:error, :not_configured}
|
||||
"""
|
||||
@spec test_connection(String.t() | nil, String.t() | nil, String.t() | nil) ::
|
||||
{:ok, :connected} | {:error, term()}
|
||||
def test_connection(api_url, api_key, club_id) do
|
||||
if blank?(api_url) or blank?(api_key) or blank?(club_id) do
|
||||
{:error, :not_configured}
|
||||
else
|
||||
url =
|
||||
api_url
|
||||
|> String.trim_trailing("/")
|
||||
|> then(&"#{&1}/finance-contacts?page[size]=1")
|
||||
|
||||
case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do
|
||||
{:ok, %{status: 200}} ->
|
||||
{:ok, :connected}
|
||||
|
||||
{:ok, %{status: status, body: body}} ->
|
||||
{:error, {:http, status, extract_error_message(body)}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, {:request_failed, reason}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp blank?(nil), do: true
|
||||
defp blank?(s) when is_binary(s), do: String.trim(s) == ""
|
||||
defp blank?(_), do: true
|
||||
|
||||
@doc """
|
||||
Creates a finance contact in Vereinfacht for the given member.
|
||||
|
||||
Returns the contact ID on success. Does not update the member record;
|
||||
the caller (e.g. SyncContact change) must persist `vereinfacht_contact_id`.
|
||||
|
||||
## Options
|
||||
- None; URL, API key, and club ID are read from Mv.Config.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_contact(member)
|
||||
{:ok, "242"}
|
||||
|
||||
iex> create_contact(member)
|
||||
{:error, {:http, 401, "Unauthenticated."}}
|
||||
"""
|
||||
@spec create_contact(struct()) :: {:ok, String.t()} | {:error, term()}
|
||||
def create_contact(member) do
|
||||
base_url = base_url()
|
||||
api_key = api_key()
|
||||
club_id = club_id()
|
||||
|
||||
if is_nil(base_url) or is_nil(api_key) or is_nil(club_id) do
|
||||
{:error, :not_configured}
|
||||
else
|
||||
body = build_create_body(member, club_id)
|
||||
url = base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts")
|
||||
post_and_parse_contact(url, body, api_key)
|
||||
end
|
||||
end
|
||||
|
||||
@sync_timeout_ms 5_000
|
||||
|
||||
# In test, skip retries so sync fails fast when no API is running (avoids log spam and long waits).
|
||||
defp req_http_options do
|
||||
opts = [receive_timeout: @sync_timeout_ms]
|
||||
if Mix.env() == :test, do: [retry: false] ++ opts, else: opts
|
||||
end
|
||||
|
||||
defp post_and_parse_contact(url, body, api_key) do
|
||||
encoded_body = Jason.encode!(body)
|
||||
|
||||
case Req.post(url, [body: encoded_body, headers: headers(api_key)] ++ req_http_options()) do
|
||||
{:ok, %{status: 201, body: resp_body}} ->
|
||||
case get_contact_id_from_response(resp_body) do
|
||||
nil -> {:error, {:invalid_response, resp_body}}
|
||||
id -> {:ok, id}
|
||||
end
|
||||
|
||||
{:ok, %{status: status, body: resp_body}} ->
|
||||
{:error, {:http, status, extract_error_message(resp_body)}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, {:request_failed, reason}}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates an existing finance contact in Vereinfacht.
|
||||
|
||||
Only sends attributes that are typically synced from the member (name, email,
|
||||
address fields). Returns the same contact_id on success.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_contact("242", member)
|
||||
{:ok, "242"}
|
||||
|
||||
iex> update_contact("242", member)
|
||||
{:error, {:http, 404, "Not Found"}}
|
||||
"""
|
||||
@spec update_contact(String.t(), struct()) :: {:ok, String.t()} | {:error, term()}
|
||||
def update_contact(contact_id, member) when is_binary(contact_id) do
|
||||
base_url = base_url()
|
||||
api_key = api_key()
|
||||
|
||||
if is_nil(base_url) or is_nil(api_key) do
|
||||
{:error, :not_configured}
|
||||
else
|
||||
body = build_update_body(contact_id, member)
|
||||
encoded_body = Jason.encode!(body)
|
||||
|
||||
url =
|
||||
base_url
|
||||
|> String.trim_trailing("/")
|
||||
|> then(&"#{&1}/finance-contacts/#{contact_id}")
|
||||
|
||||
case Req.patch(
|
||||
url,
|
||||
[
|
||||
body: encoded_body,
|
||||
headers: headers(api_key)
|
||||
] ++ req_http_options()
|
||||
) do
|
||||
{:ok, %{status: 200, body: _resp_body}} ->
|
||||
{:ok, contact_id}
|
||||
|
||||
{:ok, %{status: status, body: body}} ->
|
||||
{:error, {:http, status, extract_error_message(body)}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, {:request_failed, reason}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Finds a finance contact by email (GET /finance-contacts, then match in response).
|
||||
|
||||
The Vereinfacht API does not allow filter by email on this endpoint, so we
|
||||
fetch the first page and find the contact client-side. Returns {:ok, contact_id}
|
||||
if a contact with that email exists, {:error, :not_found} if none, or
|
||||
{:error, reason} on API/network failure. Used before create for idempotency.
|
||||
"""
|
||||
@spec find_contact_by_email(String.t()) :: {:ok, String.t()} | {:error, :not_found | term()}
|
||||
def find_contact_by_email(email) when is_binary(email) do
|
||||
if is_nil(base_url()) or is_nil(api_key()) or is_nil(club_id()) do
|
||||
{:error, :not_configured}
|
||||
else
|
||||
do_find_contact_by_email(email)
|
||||
end
|
||||
end
|
||||
|
||||
@find_contact_page_size 100
|
||||
@find_contact_max_pages 100
|
||||
|
||||
defp do_find_contact_by_email(email) do
|
||||
normalized = String.trim(email) |> String.downcase()
|
||||
do_find_contact_by_email_page(1, normalized)
|
||||
end
|
||||
|
||||
defp do_find_contact_by_email_page(page, _normalized) when page > @find_contact_max_pages do
|
||||
{:error, :not_found}
|
||||
end
|
||||
|
||||
defp do_find_contact_by_email_page(page, normalized) do
|
||||
base = base_url() |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts")
|
||||
url = base <> "?page[size]=#{@find_contact_page_size}&page[number]=#{page}"
|
||||
|
||||
case Req.get(url, [headers: headers(api_key())] ++ req_http_options()) do
|
||||
{:ok, %{status: 200, body: body}} when is_map(body) ->
|
||||
handle_find_contact_page_response(body, page, normalized)
|
||||
|
||||
{:ok, %{status: status, body: body}} ->
|
||||
{:error, {:http, status, extract_error_message(body)}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, {:request_failed, reason}}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_find_contact_page_response(body, page, normalized) do
|
||||
case find_contact_id_by_email_in_list(body, normalized) do
|
||||
id when is_binary(id) -> {:ok, id}
|
||||
nil -> maybe_find_contact_next_page(body, page, normalized)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_find_contact_next_page(body, page, normalized) do
|
||||
data = Map.get(body, "data") || []
|
||||
|
||||
if length(data) < @find_contact_page_size,
|
||||
do: {:error, :not_found},
|
||||
else: do_find_contact_by_email_page(page + 1, normalized)
|
||||
end
|
||||
|
||||
defp find_contact_id_by_email_in_list(%{"data" => list}, normalized) when is_list(list) do
|
||||
Enum.find_value(list, fn
|
||||
%{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => true}}
|
||||
when is_binary(att_email) ->
|
||||
if att_email |> String.trim() |> String.downcase() == normalized do
|
||||
normalize_contact_id(id)
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
%{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => "true"}}
|
||||
when is_binary(att_email) ->
|
||||
if att_email |> String.trim() |> String.downcase() == normalized do
|
||||
normalize_contact_id(id)
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
%{"id" => _id, "attributes" => _} ->
|
||||
nil
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end)
|
||||
end
|
||||
|
||||
defp find_contact_id_by_email_in_list(_, _), do: nil
|
||||
|
||||
defp normalize_contact_id(id) when is_binary(id), do: id
|
||||
defp normalize_contact_id(id) when is_integer(id), do: to_string(id)
|
||||
defp normalize_contact_id(_), do: nil
|
||||
|
||||
@doc """
|
||||
Fetches a single finance contact from Vereinfacht (GET /finance-contacts/:id).
|
||||
|
||||
Returns the full response body (decoded JSON) for debugging/display.
|
||||
"""
|
||||
@spec get_contact(String.t()) :: {:ok, map()} | {:error, term()}
|
||||
def get_contact(contact_id) when is_binary(contact_id) do
|
||||
fetch_contact(contact_id, [])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetches a finance contact with receipts (GET /finance-contacts/:id?include=receipts).
|
||||
|
||||
Returns {:ok, receipts} where receipts is a list of maps with :id and :attributes
|
||||
(and optional :type) for each receipt, or {:error, reason}.
|
||||
"""
|
||||
@spec get_contact_with_receipts(String.t()) :: {:ok, [map()]} | {:error, term()}
|
||||
def get_contact_with_receipts(contact_id) when is_binary(contact_id) do
|
||||
case fetch_contact(contact_id, include: "receipts") do
|
||||
{:ok, body} -> {:ok, extract_receipts_from_response(body)}
|
||||
{:error, _} = err -> err
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_contact(contact_id, query_params) do
|
||||
base_url = base_url()
|
||||
api_key = api_key()
|
||||
|
||||
if is_nil(base_url) or is_nil(api_key) do
|
||||
{:error, :not_configured}
|
||||
else
|
||||
path =
|
||||
base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}")
|
||||
|
||||
url = build_url_with_params(path, query_params)
|
||||
|
||||
case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do
|
||||
{:ok, %{status: 200, body: body}} when is_map(body) ->
|
||||
{:ok, body}
|
||||
|
||||
{:ok, %{status: status, body: body}} ->
|
||||
{:error, {:http, status, extract_error_message(body)}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, {:request_failed, reason}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp build_url_with_params(base, []), do: base
|
||||
|
||||
defp build_url_with_params(base, include: value) do
|
||||
sep = if String.contains?(base, "?"), do: "&", else: "?"
|
||||
base <> sep <> "include=" <> URI.encode(value, &URI.char_unreserved?/1)
|
||||
end
|
||||
|
||||
# Allowlist of receipt attribute keys we expose (avoids String.to_atom on arbitrary API input / DoS).
|
||||
@receipt_attr_allowlist ~w[amount bookingDate createdAt receiptType referenceNumber status updatedAt]a
|
||||
|
||||
defp extract_receipts_from_response(%{"included" => included}) when is_list(included) do
|
||||
included
|
||||
|> Enum.filter(&match?(%{"type" => "receipts"}, &1))
|
||||
|> Enum.map(fn %{"id" => id, "attributes" => attrs} = r ->
|
||||
Map.merge(%{id: id, type: r["type"]}, receipt_attrs_allowlist(attrs || %{}))
|
||||
end)
|
||||
end
|
||||
|
||||
defp extract_receipts_from_response(_), do: []
|
||||
|
||||
defp receipt_attrs_allowlist(attrs) when is_map(attrs) do
|
||||
Map.new(@receipt_attr_allowlist, fn key ->
|
||||
str_key = to_string(key)
|
||||
{key, Map.get(attrs, str_key)}
|
||||
end)
|
||||
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp base_url, do: Mv.Config.vereinfacht_api_url()
|
||||
defp api_key, do: Mv.Config.vereinfacht_api_key()
|
||||
defp club_id, do: Mv.Config.vereinfacht_club_id()
|
||||
|
||||
defp headers(api_key) do
|
||||
[
|
||||
{"Accept", @content_type},
|
||||
{"Content-Type", @content_type},
|
||||
{"Authorization", "Bearer #{api_key}"}
|
||||
]
|
||||
end
|
||||
|
||||
defp build_create_body(member, club_id) do
|
||||
attributes = member_to_attributes(member)
|
||||
|
||||
%{
|
||||
"data" => %{
|
||||
"type" => "finance-contacts",
|
||||
"attributes" => attributes,
|
||||
"relationships" => %{
|
||||
"club" => %{
|
||||
"data" => %{"type" => "clubs", "id" => club_id}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp build_update_body(contact_id, member) do
|
||||
attributes = member_to_attributes(member)
|
||||
|
||||
%{
|
||||
"data" => %{
|
||||
"type" => "finance-contacts",
|
||||
"id" => contact_id,
|
||||
"attributes" => attributes
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp member_to_attributes(member) do
|
||||
address =
|
||||
[member |> Map.get(:street), member |> Map.get(:house_number)]
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.map_join(" ", &to_string/1)
|
||||
|> then(fn s -> if s == "", do: nil, else: s end)
|
||||
|
||||
%{}
|
||||
|> put_attr("lastName", member |> Map.get(:last_name))
|
||||
|> put_attr("firstName", member |> Map.get(:first_name))
|
||||
|> put_attr("email", member |> Map.get(:email))
|
||||
|> put_attr("address", address)
|
||||
|> put_attr("zipCode", member |> Map.get(:postal_code))
|
||||
|> put_attr("city", member |> Map.get(:city))
|
||||
|> Map.put("contactType", "person")
|
||||
|> Map.put("isExternal", true)
|
||||
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp put_attr(acc, _key, nil), do: acc
|
||||
defp put_attr(acc, key, value), do: Map.put(acc, key, to_string(value))
|
||||
|
||||
defp get_contact_id_from_response(%{"data" => %{"id" => id}}) when is_binary(id), do: id
|
||||
|
||||
defp get_contact_id_from_response(%{"data" => %{"id" => id}}) when is_integer(id),
|
||||
do: to_string(id)
|
||||
|
||||
defp get_contact_id_from_response(_), do: nil
|
||||
|
||||
defp extract_error_message(%{"errors" => [%{"detail" => d} | _]}) when is_binary(d), do: d
|
||||
defp extract_error_message(%{"errors" => [%{"title" => t} | _]}) when is_binary(t), do: t
|
||||
defp extract_error_message(body) when is_map(body), do: inspect(body)
|
||||
|
||||
defp extract_error_message(body) when is_binary(body) do
|
||||
trimmed = String.trim(body)
|
||||
|
||||
if String.starts_with?(trimmed, "<") do
|
||||
:html_response
|
||||
else
|
||||
trimmed
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_error_message(other), do: inspect(other)
|
||||
end
|
||||
46
lib/mv/vereinfacht/sync_flash.ex
Normal file
46
lib/mv/vereinfacht/sync_flash.ex
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
defmodule Mv.Vereinfacht.SyncFlash do
|
||||
@moduledoc """
|
||||
Short-lived store for Vereinfacht sync results so the UI can show them after save.
|
||||
|
||||
The SyncContact change runs in after_transaction and cannot access the LiveView
|
||||
socket. This module stores a message keyed by member_id; the form LiveView
|
||||
calls `take/1` after a successful save and displays the message in flash.
|
||||
"""
|
||||
@table :vereinfacht_sync_flash
|
||||
|
||||
@doc """
|
||||
Stores a sync result for the given member. Overwrites any previous message.
|
||||
|
||||
- `:ok` - Sync succeeded (optional user message).
|
||||
- `:warning` - Sync failed; message should be shown as a warning.
|
||||
"""
|
||||
@spec store(String.t(), :ok | :warning, String.t()) :: :ok
|
||||
def store(member_id, kind, message) when is_binary(member_id) do
|
||||
:ets.insert(@table, {member_id, {kind, message}})
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Takes and removes the stored sync message for the given member.
|
||||
|
||||
Returns `{kind, message}` if present, otherwise `nil`.
|
||||
"""
|
||||
@spec take(String.t()) :: {:ok | :warning, String.t()} | nil
|
||||
def take(member_id) when is_binary(member_id) do
|
||||
case :ets.take(@table, member_id) do
|
||||
[{^member_id, value}] -> value
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
def create_table! do
|
||||
# :public so any process can write (SyncContact runs in LiveView/Ash transaction process,
|
||||
# not the process that created the table). :protected would restrict writes to the creating process.
|
||||
if :ets.whereis(@table) == :undefined do
|
||||
:ets.new(@table, [:set, :public, :named_table])
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
186
lib/mv/vereinfacht/vereinfacht.ex
Normal file
186
lib/mv/vereinfacht/vereinfacht.ex
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
defmodule Mv.Vereinfacht do
|
||||
@moduledoc """
|
||||
Business logic for Vereinfacht accounting software integration.
|
||||
|
||||
- `sync_member/1` – Sync a single member to the API (create or update contact).
|
||||
Used by Member create/update (SyncContact) and by User actions that update
|
||||
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.Vereinfacht.Client
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Helpers
|
||||
|
||||
@doc """
|
||||
Tests the connection to the Vereinfacht API using the current configuration.
|
||||
|
||||
Delegates to `Mv.Vereinfacht.Client.test_connection/3` with the values from
|
||||
`Mv.Config` (ENV variables take priority over database settings).
|
||||
|
||||
## Returns
|
||||
- `{:ok, :connected}` – credentials are valid and API is reachable
|
||||
- `{:error, :not_configured}` – URL, API key or club ID is missing
|
||||
- `{:error, {:http, status, message}}` – API returned an error (e.g. 401, 403)
|
||||
- `{:error, {:request_failed, reason}}` – network/transport error
|
||||
"""
|
||||
@spec test_connection() :: {:ok, :connected} | {:error, term()}
|
||||
def test_connection do
|
||||
Client.test_connection(
|
||||
Mv.Config.vereinfacht_api_url(),
|
||||
Mv.Config.vereinfacht_api_key(),
|
||||
Mv.Config.vereinfacht_club_id()
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Syncs a single member to Vereinfacht (create or update finance contact).
|
||||
|
||||
If the member has no `vereinfacht_contact_id`, creates a contact and updates
|
||||
the member with the new ID. If they already have an ID, updates the contact.
|
||||
Uses system actor for any Ash update. Does nothing if Vereinfacht is not configured.
|
||||
|
||||
Returns:
|
||||
- `:ok` – Contact was updated.
|
||||
- `{:ok, member}` – Contact was created and member was updated with the new ID.
|
||||
- `{:error, reason}` – API or update failed.
|
||||
"""
|
||||
@spec sync_member(struct()) :: :ok | {:ok, struct()} | {:error, term()}
|
||||
def sync_member(member) do
|
||||
if Mv.Config.vereinfacht_configured?() do
|
||||
do_sync_member(member)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp do_sync_member(member) do
|
||||
if present_contact_id?(member.vereinfacht_contact_id) do
|
||||
sync_existing_contact(member)
|
||||
else
|
||||
ensure_contact_then_save(member)
|
||||
end
|
||||
end
|
||||
|
||||
defp sync_existing_contact(member) do
|
||||
case Client.update_contact(member.vereinfacht_contact_id, member) do
|
||||
{:ok, _} -> :ok
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_contact_then_save(member) do
|
||||
case get_or_create_contact_id(member) do
|
||||
{:ok, contact_id} -> save_contact_id(member, contact_id)
|
||||
{:error, _} = err -> err
|
||||
end
|
||||
end
|
||||
|
||||
# Before create: find by email to avoid duplicate contacts (idempotency).
|
||||
# When an existing contact is found, update it with current member data.
|
||||
defp get_or_create_contact_id(member) do
|
||||
email = member |> Map.get(:email) |> to_string() |> String.trim()
|
||||
|
||||
if email == "" do
|
||||
Client.create_contact(member)
|
||||
else
|
||||
case Client.find_contact_by_email(email) do
|
||||
{:ok, existing_id} -> update_existing_contact_and_return_id(existing_id, member)
|
||||
{:error, :not_found} -> Client.create_contact(member)
|
||||
{:error, _} = err -> err
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp update_existing_contact_and_return_id(contact_id, member) do
|
||||
case Client.update_contact(contact_id, member) do
|
||||
{:ok, _} -> {:ok, contact_id}
|
||||
{:error, _} = err -> err
|
||||
end
|
||||
end
|
||||
|
||||
defp save_contact_id(member, contact_id) do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
opts = Helpers.ash_actor_opts(system_actor)
|
||||
|
||||
case Ash.update(member, %{vereinfacht_contact_id: contact_id}, [
|
||||
{:action, :set_vereinfacht_contact_id} | opts
|
||||
]) do
|
||||
{:ok, updated} -> {:ok, updated}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp present_contact_id?(nil), do: false
|
||||
defp present_contact_id?(""), do: false
|
||||
defp present_contact_id?(s) when is_binary(s), do: String.trim(s) != ""
|
||||
defp present_contact_id?(_), do: false
|
||||
|
||||
@doc """
|
||||
Formats an API/request error reason into a short user-facing message.
|
||||
|
||||
Used by SyncContact (flash) and GlobalSettingsLive (sync result list).
|
||||
"""
|
||||
@spec format_error(term()) :: String.t()
|
||||
def format_error({:http, _status, detail}) when is_binary(detail), do: "Vereinfacht: " <> detail
|
||||
def format_error({:http, status, _}), do: "Vereinfacht: API error (HTTP #{status})."
|
||||
|
||||
def format_error({:request_failed, _}),
|
||||
do: "Vereinfacht: Request failed (e.g. connection error)."
|
||||
|
||||
def format_error({:invalid_response, _}), do: "Vereinfacht: Invalid API response."
|
||||
def format_error(other), do: "Vereinfacht: " <> inspect(other)
|
||||
|
||||
@doc """
|
||||
Creates Vereinfacht contacts for all members that do not yet have a
|
||||
`vereinfacht_contact_id`. Uses system actor for reads and updates.
|
||||
|
||||
Returns `{:ok, %{synced: count, errors: list}}` where errors is a list of
|
||||
`{member_id, reason}`. Does nothing if Vereinfacht is not configured.
|
||||
"""
|
||||
@spec sync_members_without_contact() ::
|
||||
{:ok, %{synced: non_neg_integer(), errors: [{String.t(), term()}]}}
|
||||
| {:error, :not_configured}
|
||||
def sync_members_without_contact do
|
||||
if Mv.Config.vereinfacht_configured?() do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
opts = Helpers.ash_actor_opts(system_actor)
|
||||
|
||||
query =
|
||||
Member
|
||||
|> Ash.Query.filter(
|
||||
expr(is_nil(^ref(:vereinfacht_contact_id)) or ^ref(:vereinfacht_contact_id) == "")
|
||||
)
|
||||
|
||||
case Ash.read(query, opts) do
|
||||
{:ok, members} ->
|
||||
do_sync_members(members, opts)
|
||||
|
||||
{:error, _} = err ->
|
||||
err
|
||||
end
|
||||
else
|
||||
{:error, :not_configured}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_sync_members(members, opts) do
|
||||
{synced, errors} =
|
||||
Enum.reduce(members, {0, []}, fn member, {acc_synced, acc_errors} ->
|
||||
{inc, new_errors} = sync_one_member(member, opts)
|
||||
{acc_synced + inc, acc_errors ++ new_errors}
|
||||
end)
|
||||
|
||||
{:ok, %{synced: synced, errors: errors}}
|
||||
end
|
||||
|
||||
defp sync_one_member(member, _opts) do
|
||||
case sync_member(member) do
|
||||
:ok -> {1, []}
|
||||
{:ok, _} -> {1, []}
|
||||
{:error, reason} -> {0, [{member.id, reason}]}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -38,11 +38,16 @@ defmodule MvWeb.AuthOverrides do
|
|||
set :image_url, nil
|
||||
end
|
||||
|
||||
# Translate the or in the horizontal rule to German
|
||||
# Translate the "or" in the horizontal rule (between password form and SSO).
|
||||
# Uses auth domain so it respects the current locale (e.g. "oder" in German).
|
||||
override AshAuthentication.Phoenix.Components.HorizontalRule do
|
||||
set :text,
|
||||
Gettext.with_locale(MvWeb.Gettext, "de", fn ->
|
||||
Gettext.gettext(MvWeb.Gettext, "or")
|
||||
end)
|
||||
set :text, dgettext("auth", "or")
|
||||
end
|
||||
|
||||
# Hide AshAuthentication's Flash component since we use flash_group in root layout
|
||||
# This prevents duplicate flash messages
|
||||
override AshAuthentication.Phoenix.Components.Flash do
|
||||
set :message_class_info, "hidden"
|
||||
set :message_class_error, "hidden"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -448,6 +448,8 @@ defmodule MvWeb.CoreComponents do
|
|||
end
|
||||
|
||||
def input(%{type: "select"} = assigns) do
|
||||
assigns = ensure_aria_required_for_input(assigns)
|
||||
|
||||
~H"""
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
|
|
@ -475,6 +477,8 @@ defmodule MvWeb.CoreComponents do
|
|||
end
|
||||
|
||||
def input(%{type: "textarea"} = assigns) do
|
||||
assigns = ensure_aria_required_for_input(assigns)
|
||||
|
||||
~H"""
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
|
|
@ -502,6 +506,8 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||
def input(assigns) do
|
||||
assigns = ensure_aria_required_for_input(assigns)
|
||||
|
||||
~H"""
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
|
|
@ -529,6 +535,18 @@ defmodule MvWeb.CoreComponents do
|
|||
"""
|
||||
end
|
||||
|
||||
# WCAG 2.1: set aria-required when required is true so screen readers announce required state
|
||||
defp ensure_aria_required_for_input(assigns) do
|
||||
rest = assigns.rest || %{}
|
||||
|
||||
rest =
|
||||
if rest[:required],
|
||||
do: Map.put(rest, :aria_required, "true"),
|
||||
else: rest
|
||||
|
||||
assign(assigns, :rest, rest)
|
||||
end
|
||||
|
||||
# Helper used by inputs to generate form errors
|
||||
defp error(assigns) do
|
||||
~H"""
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ defmodule MvWeb.Layouts do
|
|||
data-sidebar-expanded="true"
|
||||
phx-hook="SidebarState"
|
||||
>
|
||||
<input id="mobile-drawer" type="checkbox" class="drawer-toggle" />
|
||||
<input id="mobile-drawer" type="checkbox" class="drawer-toggle" phx-update="ignore" />
|
||||
|
||||
<div class="drawer-content flex flex-col relative z-0">
|
||||
<!-- Mobile Header (only visible on mobile) -->
|
||||
|
|
|
|||
|
|
@ -15,24 +15,98 @@
|
|||
</script>
|
||||
<script>
|
||||
(() => {
|
||||
const setTheme = (theme) => {
|
||||
if (theme === "system") {
|
||||
localStorage.removeItem("phx:theme");
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
} else {
|
||||
localStorage.setItem("phx:theme", theme);
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const systemTheme = () => (mq.matches ? "dark" : "light");
|
||||
|
||||
// Single source of truth:
|
||||
// - localStorage["phx:theme"] = "light" | "dark" (explicit override)
|
||||
// - missing key => "system"
|
||||
const storedTheme = () => localStorage.getItem("phx:theme") || "system";
|
||||
|
||||
const effectiveTheme = (t) => (t === "system" ? systemTheme() : t);
|
||||
|
||||
const applyThemeNow = (t) => {
|
||||
document.documentElement.setAttribute("data-theme", effectiveTheme(t));
|
||||
};
|
||||
if (!document.documentElement.hasAttribute("data-theme")) {
|
||||
setTheme(localStorage.getItem("phx:theme") || "system");
|
||||
}
|
||||
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
|
||||
|
||||
const syncToggle = () => {
|
||||
const eff = effectiveTheme(storedTheme());
|
||||
document.querySelectorAll("[data-theme-toggle]").forEach((el) => {
|
||||
el.checked = eff === "dark";
|
||||
});
|
||||
};
|
||||
|
||||
const setTheme = (t) => {
|
||||
if (t === "system") localStorage.removeItem("phx:theme");
|
||||
else localStorage.setItem("phx:theme", t);
|
||||
|
||||
applyThemeNow(t);
|
||||
syncToggle(); // if toggle exists already
|
||||
};
|
||||
|
||||
// 1) Apply theme ASAP to match system on first paint
|
||||
applyThemeNow(storedTheme());
|
||||
|
||||
// 2) Sync toggle once DOM is ready (fixes initial "light" toggle)
|
||||
document.addEventListener("DOMContentLoaded", syncToggle);
|
||||
|
||||
// 3) If toggle appears later (LiveView render), sync immediately
|
||||
const obs = new MutationObserver(() => {
|
||||
if (document.querySelector("[data-theme-toggle]")) syncToggle();
|
||||
});
|
||||
obs.observe(document.documentElement, { childList: true, subtree: true });
|
||||
|
||||
window.addEventListener("phx:set-theme", ({ detail: { theme } }) => setTheme(theme));
|
||||
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
|
||||
|
||||
mq.addEventListener("change", () => {
|
||||
if (localStorage.getItem("phx:theme") === null) {
|
||||
applyThemeNow("system");
|
||||
syncToggle();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="flash-group-root"
|
||||
aria-live="polite"
|
||||
class="z-50 flex flex-col gap-2 toast toast-top toast-end"
|
||||
>
|
||||
<.flash id="flash-success-root" kind={:success} flash={@flash} />
|
||||
<.flash id="flash-warning-root" kind={:warning} flash={@flash} />
|
||||
<.flash id="flash-info-root" kind={:info} flash={@flash} />
|
||||
<.flash id="flash-error-root" kind={:error} flash={@flash} />
|
||||
|
||||
<.flash
|
||||
id="client-error-root"
|
||||
kind={:error}
|
||||
title={gettext("We can't find the internet")}
|
||||
phx-disconnected={
|
||||
show(".phx-client-error #client-error-root") |> JS.remove_attribute("hidden")
|
||||
}
|
||||
phx-connected={hide("#client-error-root") |> JS.set_attribute({"hidden", ""})}
|
||||
hidden
|
||||
>
|
||||
{gettext("Attempting to reconnect")}
|
||||
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||
</.flash>
|
||||
|
||||
<.flash
|
||||
id="server-error-root"
|
||||
kind={:error}
|
||||
title={gettext("Something went wrong!")}
|
||||
phx-disconnected={
|
||||
show(".phx-server-error #server-error-root") |> JS.remove_attribute("hidden")
|
||||
}
|
||||
phx-connected={hide("#server-error-root") |> JS.set_attribute({"hidden", ""})}
|
||||
hidden
|
||||
>
|
||||
{gettext("Attempting to reconnect")}
|
||||
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||
</.flash>
|
||||
</div>
|
||||
{@inner_content}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -80,11 +80,11 @@ defmodule MvWeb.Layouts.Sidebar do
|
|||
/>
|
||||
<% end %>
|
||||
|
||||
<%= if can_access_page?(@current_user, PagePaths.membership_fee_types()) do %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.groups()) do %>
|
||||
<.menu_item
|
||||
href={~p"/membership_fee_types"}
|
||||
icon="hero-currency-euro"
|
||||
label={gettext("Fee Types")}
|
||||
href={~p"/groups"}
|
||||
icon="hero-user-group"
|
||||
label={gettext("Groups")}
|
||||
/>
|
||||
<% end %>
|
||||
|
||||
|
|
@ -102,24 +102,26 @@ defmodule MvWeb.Layouts.Sidebar do
|
|||
label={gettext("Administration")}
|
||||
testid="sidebar-administration"
|
||||
>
|
||||
<%= if can_access_page?(@current_user, PagePaths.users()) do %>
|
||||
<.menu_subitem href={~p"/users"} label={gettext("Users")} />
|
||||
<%= if can_access_page?(@current_user, PagePaths.settings()) do %>
|
||||
<.menu_subitem href={~p"/settings"} label={gettext("Basic settings")} />
|
||||
<% end %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.groups()) do %>
|
||||
<.menu_subitem href={~p"/groups"} label={gettext("Groups")} />
|
||||
<% end %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.admin_roles()) do %>
|
||||
<.menu_subitem href={~p"/admin/roles"} label={gettext("Roles")} />
|
||||
<%= if can_access_page?(@current_user, PagePaths.admin_datafields()) do %>
|
||||
<.menu_subitem href={~p"/admin/datafields"} label={gettext("Datafields")} />
|
||||
<% end %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.membership_fee_settings()) do %>
|
||||
<.menu_subitem
|
||||
href={~p"/membership_fee_settings"}
|
||||
label={gettext("Fee Settings")}
|
||||
label={gettext("Membership fee settings")}
|
||||
/>
|
||||
<% end %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.settings()) do %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.admin_import()) do %>
|
||||
<.menu_subitem href={~p"/admin/import"} label={gettext("Import")} />
|
||||
<.menu_subitem href={~p"/settings"} label={gettext("Settings")} />
|
||||
<% end %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.users()) do %>
|
||||
<.menu_subitem href={~p"/users"} label={gettext("Users")} />
|
||||
<% end %>
|
||||
<%= if can_access_page?(@current_user, PagePaths.admin_roles()) do %>
|
||||
<.menu_subitem href={~p"/admin/roles"} label={gettext("Roles")} />
|
||||
<% end %>
|
||||
</.menu_group>
|
||||
<% end %>
|
||||
|
|
@ -248,12 +250,17 @@ defmodule MvWeb.Layouts.Sidebar do
|
|||
aria-label={gettext("Toggle dark mode")}
|
||||
>
|
||||
<.icon name="hero-sun" class="size-5" aria-hidden="true" />
|
||||
<input
|
||||
type="checkbox"
|
||||
value="dark"
|
||||
class="toggle toggle-sm theme-controller focus:outline-none"
|
||||
aria-label={gettext("Toggle dark mode")}
|
||||
/>
|
||||
<div id="theme-toggle" phx-update="ignore">
|
||||
<input
|
||||
id="theme-toggle-input"
|
||||
type="checkbox"
|
||||
class="toggle toggle-sm focus:outline-none"
|
||||
data-theme-toggle
|
||||
onchange="window.dispatchEvent(new CustomEvent('phx:set-theme',{detail:{theme:this.checked?'dark':'light'}}))"
|
||||
aria-label={gettext("Toggle dark mode")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<.icon name="hero-moon" class="size-5" aria-hidden="true" />
|
||||
</label>
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -45,28 +45,86 @@ defmodule MvWeb.AuthController do
|
|||
- Generic authentication failures
|
||||
"""
|
||||
def failure(conn, activity, reason) do
|
||||
Logger.warning(
|
||||
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
|
||||
)
|
||||
log_failure_safely(activity, reason)
|
||||
|
||||
case {activity, reason} do
|
||||
{{:rauthy, _action}, reason} ->
|
||||
handle_rauthy_failure(conn, reason)
|
||||
{{:oidc, _action}, reason} ->
|
||||
handle_oidc_failure(conn, reason)
|
||||
|
||||
{_, %AshAuthentication.Errors.AuthenticationFailed{caused_by: caused_by}} ->
|
||||
handle_authentication_failed(conn, caused_by)
|
||||
|
||||
_ ->
|
||||
redirect_with_error(conn, gettext("Incorrect email or password"))
|
||||
conn
|
||||
|> put_flash(:error, gettext("Incorrect email or password"))
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
end
|
||||
|
||||
# Handle all Rauthy (OIDC) authentication failures
|
||||
defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do
|
||||
# Log authentication failures safely, avoiding sensitive data for {:oidc, _} activities
|
||||
defp log_failure_safely({:oidc, _action} = activity, reason) do
|
||||
# For Assent errors, use safe_assent_meta to avoid logging tokens/URLs with query params
|
||||
case reason do
|
||||
%Assent.ServerUnreachableError{} = err ->
|
||||
meta = safe_assent_meta(err)
|
||||
message = format_safe_log_message("Authentication failure", activity, meta)
|
||||
Logger.warning(message)
|
||||
|
||||
%Assent.InvalidResponseError{} = err ->
|
||||
meta = safe_assent_meta(err)
|
||||
message = format_safe_log_message("Authentication failure", activity, meta)
|
||||
Logger.warning(message)
|
||||
|
||||
_ ->
|
||||
# For other OIDC errors, log only error type, not full details
|
||||
error_type = get_error_type(reason)
|
||||
|
||||
Logger.warning(
|
||||
"Authentication failure - Activity: #{inspect(activity)}, Error type: #{error_type}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp log_failure_safely(activity, reason) do
|
||||
# For non-OIDC activities, safe to log full reason
|
||||
Logger.warning(
|
||||
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
|
||||
)
|
||||
end
|
||||
|
||||
# Extract safe error type identifier without sensitive data
|
||||
defp get_error_type(%struct{}), do: "#{struct}"
|
||||
defp get_error_type(atom) when is_atom(atom), do: inspect(atom)
|
||||
defp get_error_type(_other), do: "[redacted]"
|
||||
|
||||
# Format safe log message with metadata included in the message string
|
||||
defp format_safe_log_message(base_message, activity, meta) when is_list(meta) do
|
||||
activity_str = "Activity: #{inspect(activity)}"
|
||||
meta_str = format_meta_string(meta)
|
||||
"#{base_message} - #{activity_str}#{meta_str}"
|
||||
end
|
||||
|
||||
defp format_meta_string([]), do: ""
|
||||
|
||||
defp format_meta_string(meta) when is_list(meta) do
|
||||
parts =
|
||||
Enum.map(meta, fn
|
||||
{:request_url, url} -> "Request URL: #{url}"
|
||||
{:status, status} -> "Status: #{status}"
|
||||
{:http_adapter, adapter} -> "HTTP Adapter: #{inspect(adapter)}"
|
||||
_ -> nil
|
||||
end)
|
||||
|> Enum.filter(&(&1 != nil))
|
||||
|
||||
if Enum.empty?(parts), do: "", else: " - " <> Enum.join(parts, ", ")
|
||||
end
|
||||
|
||||
# Handle all OIDC authentication failures
|
||||
defp handle_oidc_failure(conn, %Ash.Error.Invalid{errors: errors}) do
|
||||
handle_oidc_email_collision(conn, errors)
|
||||
end
|
||||
|
||||
defp handle_rauthy_failure(conn, %AshAuthentication.Errors.AuthenticationFailed{
|
||||
defp handle_oidc_failure(conn, %AshAuthentication.Errors.AuthenticationFailed{
|
||||
caused_by: caused_by
|
||||
}) do
|
||||
case caused_by do
|
||||
|
|
@ -74,14 +132,46 @@ defmodule MvWeb.AuthController do
|
|||
handle_oidc_email_collision(conn, errors)
|
||||
|
||||
_ ->
|
||||
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
|
||||
conn
|
||||
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
end
|
||||
|
||||
# Handle Assent server unreachable errors (network/connectivity issues)
|
||||
defp handle_oidc_failure(conn, %Assent.ServerUnreachableError{} = _err) do
|
||||
# Logging already done safely in failure/3 via log_failure_safely/2
|
||||
# No need to log again here to avoid duplicate logs
|
||||
|
||||
conn
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext("The authentication server is currently unavailable. Please try again later.")
|
||||
)
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
|
||||
# Handle Assent invalid response errors (configuration or malformed responses)
|
||||
defp handle_oidc_failure(conn, %Assent.InvalidResponseError{} = _err) do
|
||||
# Logging already done safely in failure/3 via log_failure_safely/2
|
||||
# No need to log again here to avoid duplicate logs
|
||||
|
||||
conn
|
||||
|> put_flash(
|
||||
:error,
|
||||
gettext("Authentication configuration error. Please contact the administrator.")
|
||||
)
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
|
||||
# Catch-all clause for any other error types
|
||||
defp handle_rauthy_failure(conn, reason) do
|
||||
Logger.warning("Unhandled Rauthy failure reason: #{inspect(reason)}")
|
||||
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
|
||||
defp handle_oidc_failure(conn, _reason) do
|
||||
# Logging already done safely in failure/3 via log_failure_safely/2
|
||||
# No need to log again here to avoid duplicate logs
|
||||
|
||||
conn
|
||||
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
|
||||
# Handle generic AuthenticationFailed errors
|
||||
|
|
@ -93,14 +183,20 @@ defmodule MvWeb.AuthController do
|
|||
You can confirm your account using the link we sent to you, or by resetting your password.
|
||||
""")
|
||||
|
||||
redirect_with_error(conn, message)
|
||||
conn
|
||||
|> put_flash(:error, message)
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
else
|
||||
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
|
||||
conn
|
||||
|> put_flash(:error, gettext("Authentication failed. Please try again."))
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_authentication_failed(conn, _other) do
|
||||
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
|
||||
conn
|
||||
|> put_flash(:error, gettext("Authentication failed. Please try again."))
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
|
||||
# Handle OIDC email collision - user needs to verify password to link accounts
|
||||
|
|
@ -112,7 +208,10 @@ defmodule MvWeb.AuthController do
|
|||
nil ->
|
||||
# Check if it's a "different OIDC account" error or email uniqueness error
|
||||
error_message = extract_meaningful_error_message(errors)
|
||||
redirect_with_error(conn, error_message)
|
||||
|
||||
conn
|
||||
|> put_flash(:error, error_message)
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -177,13 +276,47 @@ defmodule MvWeb.AuthController do
|
|||
|> redirect(to: ~p"/auth/link-oidc-account")
|
||||
end
|
||||
|
||||
# Generic error redirect helper
|
||||
defp redirect_with_error(conn, message) do
|
||||
conn
|
||||
|> put_flash(:error, message)
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
# Extract safe metadata from Assent errors for logging
|
||||
# Never logs sensitive data: no tokens, secrets, or full request URLs
|
||||
# Returns keyword list for Logger.warning/2
|
||||
defp safe_assent_meta(%{request_url: url} = err) when is_binary(url) do
|
||||
[
|
||||
request_url: redact_url(url),
|
||||
http_adapter: Map.get(err, :http_adapter)
|
||||
]
|
||||
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
|
||||
end
|
||||
|
||||
# Handle InvalidResponseError which has :response field (HTTPResponse struct)
|
||||
defp safe_assent_meta(%{response: %{status: status} = response} = err) do
|
||||
[
|
||||
status: status,
|
||||
http_adapter: Map.get(response, :http_adapter) || Map.get(err, :http_adapter)
|
||||
]
|
||||
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
|
||||
end
|
||||
|
||||
defp safe_assent_meta(err) do
|
||||
# Only extract safe, simple fields
|
||||
[
|
||||
http_adapter: Map.get(err, :http_adapter)
|
||||
]
|
||||
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
|
||||
end
|
||||
|
||||
# Redact URL to only show scheme and host, hiding path, query, and fragments
|
||||
defp redact_url(url) when is_binary(url) do
|
||||
case URI.parse(url) do
|
||||
%URI{scheme: scheme, host: host} when not is_nil(scheme) and not is_nil(host) ->
|
||||
"#{scheme}://#{host}"
|
||||
|
||||
_ ->
|
||||
"[redacted]"
|
||||
end
|
||||
end
|
||||
|
||||
defp redact_url(_), do: "[redacted]"
|
||||
|
||||
def sign_out(conn, _params) do
|
||||
return_to = get_session(conn, :return_to) || ~p"/"
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ defmodule MvWeb.MemberExportController do
|
|||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
@member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
||||
["membership_fee_type", "groups"]
|
||||
@computed_export_fields ["membership_fee_status"]
|
||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||
|
||||
|
|
@ -83,6 +84,7 @@ defmodule MvWeb.MemberExportController do
|
|||
domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
selectable = Enum.filter(member_fields, fn f -> f in domain_fields end)
|
||||
computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end)
|
||||
# "groups" is neither a domain field nor a computed field, it's handled separately
|
||||
{selectable, computed}
|
||||
end
|
||||
|
||||
|
|
@ -235,12 +237,20 @@ defmodule MvWeb.MemberExportController do
|
|||
need_cycles =
|
||||
parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields
|
||||
|
||||
need_groups = "groups" in parsed.member_fields
|
||||
|
||||
need_membership_fee_type =
|
||||
"membership_fee_type" in parsed.member_fields or
|
||||
parsed.sort_field == "membership_fee_type"
|
||||
|
||||
query =
|
||||
Member
|
||||
|> Ash.Query.new()
|
||||
|> Ash.Query.select(select_fields)
|
||||
|> load_custom_field_values_query(parsed.custom_field_ids)
|
||||
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
||||
|> maybe_load_groups(need_groups)
|
||||
|> maybe_load_membership_fee_type(need_membership_fee_type)
|
||||
|
||||
query =
|
||||
if parsed.selected_ids != [] do
|
||||
|
|
@ -284,6 +294,19 @@ defmodule MvWeb.MemberExportController do
|
|||
MembershipFeeStatus.load_cycles_for_members(query, show_current)
|
||||
end
|
||||
|
||||
defp maybe_load_groups(query, false), do: query
|
||||
|
||||
defp maybe_load_groups(query, true) do
|
||||
# Load groups with id and name only (for export formatting)
|
||||
Ash.Query.load(query, groups: [:id, :name])
|
||||
end
|
||||
|
||||
defp maybe_load_membership_fee_type(query, false), do: query
|
||||
|
||||
defp maybe_load_membership_fee_type(query, true) do
|
||||
Ash.Query.load(query, membership_fee_type: [:id, :name])
|
||||
end
|
||||
|
||||
# Adds computed field values to members (e.g. membership_fee_status)
|
||||
defp add_computed_fields(members, computed_fields, show_current_cycle) do
|
||||
if "membership_fee_status" in computed_fields do
|
||||
|
|
@ -329,22 +352,47 @@ defmodule MvWeb.MemberExportController do
|
|||
defp maybe_sort_export(query, _field, nil), do: {query, false}
|
||||
|
||||
defp maybe_sort_export(query, field, order) when is_binary(field) do
|
||||
if custom_field_sort?(field) do
|
||||
# Custom field sort → in-memory nach dem Read (wie Tabelle)
|
||||
{query, true}
|
||||
else
|
||||
field_atom = String.to_existing_atom(field)
|
||||
cond do
|
||||
field == "groups" ->
|
||||
{query, true}
|
||||
|
||||
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
|
||||
field == "membership_fee_type" ->
|
||||
apply_membership_fee_type_sort_export(query, order)
|
||||
|
||||
custom_field_sort?(field) ->
|
||||
{query, true}
|
||||
|
||||
true ->
|
||||
apply_member_field_sort_export(query, field, order)
|
||||
end
|
||||
rescue
|
||||
ArgumentError -> {query, false}
|
||||
end
|
||||
|
||||
defp apply_membership_fee_type_sort_export(query, order) do
|
||||
order_atom = if order == "desc", do: :desc, else: :asc
|
||||
{Ash.Query.sort(query, [{"membership_fee_type.name", order_atom}]), false}
|
||||
end
|
||||
|
||||
defp apply_member_field_sort_export(query, field, order) do
|
||||
field_atom = String.to_existing_atom(field)
|
||||
|
||||
sortable =
|
||||
field_atom in (Mv.Constants.member_fields() -- [:notes]) or
|
||||
field_atom == :membership_fee_type
|
||||
|
||||
if sortable do
|
||||
order_atom = if order == "desc", do: :desc, else: :asc
|
||||
|
||||
sort_field =
|
||||
if field_atom == :membership_fee_type, do: :membership_fee_type_id, else: field_atom
|
||||
|
||||
{Ash.Query.sort(query, [{sort_field, order_atom}]), false}
|
||||
else
|
||||
{query, false}
|
||||
end
|
||||
end
|
||||
|
||||
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -358,6 +406,15 @@ defmodule MvWeb.MemberExportController do
|
|||
defp sort_members_by_custom_field_export(members, field, order, custom_fields)
|
||||
when is_binary(field) do
|
||||
order = order || "asc"
|
||||
|
||||
if field == "groups" do
|
||||
sort_members_by_groups_export(members, order)
|
||||
else
|
||||
sort_by_custom_field_value(members, field, order, custom_fields)
|
||||
end
|
||||
end
|
||||
|
||||
defp sort_by_custom_field_value(members, field, order, custom_fields) do
|
||||
id_str = String.trim_leading(field, @custom_field_prefix)
|
||||
|
||||
custom_field =
|
||||
|
|
@ -387,6 +444,26 @@ defmodule MvWeb.MemberExportController do
|
|||
end
|
||||
end
|
||||
|
||||
defp sort_members_by_groups_export(members, order) do
|
||||
# Members with groups first, then by first group name alphabetically (min = first by sort order)
|
||||
# Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2
|
||||
first_group_name = fn member ->
|
||||
(member.groups || [])
|
||||
|> Enum.map(& &1.name)
|
||||
|> Enum.min(fn -> nil end)
|
||||
end
|
||||
|
||||
members
|
||||
|> Enum.sort_by(fn member ->
|
||||
name = first_group_name.(member)
|
||||
# Nil (no groups) sorts last in asc, first in desc
|
||||
{name == nil, name || ""}
|
||||
end)
|
||||
|> then(fn list ->
|
||||
if order == "desc", do: Enum.reverse(list), else: list
|
||||
end)
|
||||
end
|
||||
|
||||
defp has_non_empty_custom_field_value?(member, custom_field) do
|
||||
case find_cfv(member, custom_field) do
|
||||
nil ->
|
||||
|
|
@ -441,6 +518,32 @@ defmodule MvWeb.MemberExportController do
|
|||
}
|
||||
end)
|
||||
|
||||
membership_fee_type_col =
|
||||
if "membership_fee_type" in parsed.member_fields do
|
||||
[
|
||||
%{
|
||||
header: membership_fee_type_field_header(conn),
|
||||
kind: :membership_fee_type,
|
||||
key: :membership_fee_type
|
||||
}
|
||||
]
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
groups_col =
|
||||
if "groups" in parsed.member_fields do
|
||||
[
|
||||
%{
|
||||
header: groups_field_header(conn),
|
||||
kind: :groups,
|
||||
key: :groups
|
||||
}
|
||||
]
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
custom_cols =
|
||||
parsed.custom_field_ids
|
||||
|> Enum.map(fn id ->
|
||||
|
|
@ -459,7 +562,8 @@ defmodule MvWeb.MemberExportController do
|
|||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
member_cols ++ computed_cols ++ custom_cols
|
||||
# Table order: ... membership_fee_start_date, membership_fee_type, membership_fee_status, groups, custom
|
||||
member_cols ++ membership_fee_type_col ++ computed_cols ++ groups_col ++ custom_cols
|
||||
end
|
||||
|
||||
# --- headers: use MemberFields.label for translations ---
|
||||
|
|
@ -499,6 +603,14 @@ defmodule MvWeb.MemberExportController do
|
|||
cf.name
|
||||
end
|
||||
|
||||
defp membership_fee_type_field_header(_conn) do
|
||||
MemberFields.label(:membership_fee_type)
|
||||
end
|
||||
|
||||
defp groups_field_header(_conn) do
|
||||
MemberFields.label(:groups)
|
||||
end
|
||||
|
||||
defp humanize_field(str) do
|
||||
str
|
||||
|> String.replace("_", " ")
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ defmodule MvWeb.MemberPdfExportController do
|
|||
@invalid_json_message "invalid JSON"
|
||||
@export_failed_message "Failed to generate PDF export"
|
||||
|
||||
@allowed_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
@allowed_member_field_strings (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
||||
["membership_fee_type", "groups"]
|
||||
|
||||
def export(conn, %{"payload" => payload}) when is_binary(payload) do
|
||||
actor = current_actor(conn)
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ defmodule MvWeb.LinkOidcAccountLive do
|
|||
:info,
|
||||
dgettext("auth", "Account activated! Redirecting to complete sign-in...")
|
||||
)
|
||||
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/rauthy")
|
||||
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/oidc")
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning(
|
||||
|
|
@ -223,7 +223,7 @@ defmodule MvWeb.LinkOidcAccountLive do
|
|||
"Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
||||
)
|
||||
)
|
||||
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/rauthy")}
|
||||
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/oidc")}
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning(
|
||||
|
|
|
|||
101
lib/mv_web/live/auth/sign_in_live.ex
Normal file
101
lib/mv_web/live/auth/sign_in_live.ex
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
defmodule MvWeb.SignInLive do
|
||||
@moduledoc """
|
||||
Custom sign-in page with language selector and conditional Single Sign-On button.
|
||||
|
||||
- Renders a language selector (same pattern as LinkOidcAccountLive).
|
||||
- Wraps the default AshAuthentication SignIn component in a container with
|
||||
`data-oidc-configured` so that CSS can hide the SSO button when OIDC is not configured.
|
||||
"""
|
||||
use Phoenix.LiveView
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias AshAuthentication.Phoenix.Components
|
||||
alias Mv.Config
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
overrides =
|
||||
session
|
||||
|> Map.get("overrides", [AshAuthentication.Phoenix.Overrides.Default])
|
||||
|
||||
# Locale: same fallback as LiveUserAuth so config :default_locale (e.g. "en" in test) is respected
|
||||
locale =
|
||||
session["locale"] || Application.get_env(:mv, :default_locale, "de")
|
||||
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(overrides: overrides)
|
||||
|> assign_new(:otp_app, fn -> nil end)
|
||||
|> assign(:path, session["path"] || "/")
|
||||
|> assign(:reset_path, session["reset_path"])
|
||||
|> assign(:register_path, session["register_path"])
|
||||
|> assign(:current_tenant, session["tenant"])
|
||||
|> assign(:resources, session["resources"])
|
||||
|> assign(:context, session["context"] || %{})
|
||||
|> assign(:auth_routes_prefix, session["auth_routes_prefix"])
|
||||
|> assign(:gettext_fn, session["gettext_fn"])
|
||||
|> assign(:live_action, :sign_in)
|
||||
|> assign(:oidc_configured, Config.oidc_configured?())
|
||||
|> assign(:oidc_only, Config.oidc_only?())
|
||||
|> assign(:root_class, "grid h-screen place-items-center bg-base-100")
|
||||
|> assign(:sign_in_id, "sign-in")
|
||||
|> assign(:locale, locale)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_, _uri, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id="sign-in-page"
|
||||
class={@root_class}
|
||||
data-oidc-configured={to_string(@oidc_configured)}
|
||||
data-oidc-only={to_string(@oidc_only)}
|
||||
data-locale={@locale}
|
||||
>
|
||||
<%!-- Language selector --%>
|
||||
<nav
|
||||
aria-label={dgettext("auth", "Language selection")}
|
||||
class="absolute top-4 right-4 flex justify-end z-10"
|
||||
>
|
||||
<form method="post" action="/set_locale" class="text-sm">
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<select
|
||||
name="locale"
|
||||
onchange="this.form.submit()"
|
||||
class="select select-sm select-bordered bg-base-100"
|
||||
aria-label={dgettext("auth", "Select language")}
|
||||
>
|
||||
<option value="de" selected={@locale == "de"}>Deutsch</option>
|
||||
<option value="en" selected={@locale == "en"}>English</option>
|
||||
</select>
|
||||
</form>
|
||||
</nav>
|
||||
|
||||
<.live_component
|
||||
module={Components.SignIn}
|
||||
otp_app={@otp_app}
|
||||
live_action={@live_action}
|
||||
path={@path}
|
||||
auth_routes_prefix={@auth_routes_prefix}
|
||||
resources={@resources}
|
||||
reset_path={@reset_path}
|
||||
register_path={@register_path}
|
||||
id={@sign_in_id}
|
||||
overrides={@overrides}
|
||||
current_tenant={@current_tenant}
|
||||
context={@context}
|
||||
gettext_fn={@gettext_fn}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
132
lib/mv_web/live/datafields_live.ex
Normal file
132
lib/mv_web/live/datafields_live.ex
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
defmodule MvWeb.DatafieldsLive do
|
||||
@moduledoc """
|
||||
LiveView for managing member field visibility/required and custom fields (datafields).
|
||||
|
||||
Renders MemberFieldLive.IndexComponent and CustomFieldLive.IndexComponent.
|
||||
Moved from GlobalSettingsLive (Memberdata section) to a dedicated page.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
alias Mv.Membership
|
||||
|
||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Datafields"))
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:active_editing_section, nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user} club_name={@settings.club_name}>
|
||||
<.header>
|
||||
{gettext("Datafields")}
|
||||
<:subtitle>
|
||||
{gettext("Configure member fields and custom data fields.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form_section title={gettext("Member fields")}>
|
||||
<.live_component
|
||||
:if={@active_editing_section != :custom_fields}
|
||||
module={MvWeb.MemberFieldLive.IndexComponent}
|
||||
id="member-fields-component"
|
||||
settings={@settings}
|
||||
/>
|
||||
</.form_section>
|
||||
|
||||
<.form_section title={gettext("Custom fields")}>
|
||||
<.live_component
|
||||
:if={@active_editing_section != :member_fields}
|
||||
module={MvWeb.CustomFieldLive.IndexComponent}
|
||||
id="custom-fields-component"
|
||||
actor={@current_user}
|
||||
/>
|
||||
</.form_section>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
|
||||
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
||||
id: "custom-fields-component",
|
||||
show_form: false
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:active_editing_section, nil)
|
||||
|> put_flash(:info, gettext("Data field %{action} successfully", action: action))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_deleted, _custom_field}, socket) do
|
||||
{:noreply, put_flash(socket, :info, gettext("Data field deleted successfully"))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_delete_error, error}, socket) do
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Failed to delete data field: %{error}", error: inspect(error))
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:custom_field_slug_mismatch, socket) do
|
||||
{:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))}
|
||||
end
|
||||
|
||||
def handle_info({:custom_fields_load_error, _error}, socket) do
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Could not load data fields. Please check your permissions.")
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:editing_section_changed, section}, socket) do
|
||||
{:noreply, assign(socket, :active_editing_section, section)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:member_field_saved, _member_field, action}, socket) do
|
||||
{:ok, updated_settings} = Membership.get_settings()
|
||||
|
||||
send_update(MvWeb.MemberFieldLive.IndexComponent,
|
||||
id: "member-fields-component",
|
||||
show_form: false,
|
||||
settings: updated_settings
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:settings, updated_settings)
|
||||
|> assign(:active_editing_section, nil)
|
||||
|> put_flash(:info, gettext("Member field %{action} successfully", action: action))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:member_field_visibility_updated}, socket) do
|
||||
{:ok, updated_settings} = Membership.get_settings()
|
||||
|
||||
send_update(MvWeb.MemberFieldLive.IndexComponent,
|
||||
id: "member-fields-component",
|
||||
settings: updated_settings
|
||||
)
|
||||
|
||||
{:noreply, assign(socket, :settings, updated_settings)}
|
||||
end
|
||||
end
|
||||
|
|
@ -23,6 +23,9 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
alias Mv.Membership
|
||||
|
||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
|
|
@ -31,21 +34,43 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
def mount(_params, session, socket) do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
# Get locale from session for translations
|
||||
locale = session["locale"] || "de"
|
||||
# Get locale from session; same fallback as router/LiveUserAuth (respects config :default_locale in test)
|
||||
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, gettext("Settings"))
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:active_editing_section, nil)
|
||||
|> assign(:locale, locale)
|
||||
|> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?())
|
||||
|> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?())
|
||||
|> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?())
|
||||
|> assign(:vereinfacht_club_id_env_set, Mv.Config.vereinfacht_club_id_env_set?())
|
||||
|> assign(:vereinfacht_app_url_env_set, Mv.Config.vereinfacht_app_url_env_set?())
|
||||
|> assign(:vereinfacht_api_key_set, present?(settings.vereinfacht_api_key))
|
||||
|> assign(:last_vereinfacht_sync_result, nil)
|
||||
|> assign(:vereinfacht_test_result, nil)
|
||||
|> assign(:oidc_env_configured, Mv.Config.oidc_env_configured?())
|
||||
|> assign(:oidc_client_id_env_set, Mv.Config.oidc_client_id_env_set?())
|
||||
|> assign(:oidc_base_url_env_set, Mv.Config.oidc_base_url_env_set?())
|
||||
|> assign(:oidc_redirect_uri_env_set, Mv.Config.oidc_redirect_uri_env_set?())
|
||||
|> assign(:oidc_client_secret_env_set, Mv.Config.oidc_client_secret_env_set?())
|
||||
|> assign(:oidc_admin_group_name_env_set, Mv.Config.oidc_admin_group_name_env_set?())
|
||||
|> assign(:oidc_groups_claim_env_set, Mv.Config.oidc_groups_claim_env_set?())
|
||||
|> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?())
|
||||
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
||||
|> assign(:oidc_client_secret_set, present?(settings.oidc_client_secret))
|
||||
|> assign_form()
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
defp present?(nil), do: false
|
||||
defp present?(""), do: false
|
||||
defp present?(s) when is_binary(s), do: String.trim(s) != ""
|
||||
defp present?(_), do: false
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
@ -74,21 +99,240 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
</.button>
|
||||
</.form>
|
||||
</.form_section>
|
||||
<%!-- Memberdata Section --%>
|
||||
<.form_section title={gettext("Memberdata")}>
|
||||
<.live_component
|
||||
:if={@active_editing_section != :custom_fields}
|
||||
module={MvWeb.MemberFieldLive.IndexComponent}
|
||||
id="member-fields-component"
|
||||
settings={@settings}
|
||||
/>
|
||||
<%!-- Custom Fields Section --%>
|
||||
<.live_component
|
||||
:if={@active_editing_section != :member_fields}
|
||||
module={MvWeb.CustomFieldLive.IndexComponent}
|
||||
id="custom-fields-component"
|
||||
actor={@current_user}
|
||||
/>
|
||||
<%!-- Vereinfacht Integration Section --%>
|
||||
<.form_section title={gettext("Vereinfacht Integration")}>
|
||||
<%= if @vereinfacht_env_configured do %>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
{gettext("Some values are set via environment variables. Those fields are read-only.")}
|
||||
</p>
|
||||
<% end %>
|
||||
<.form for={@form} id="vereinfacht-form" phx-change="validate" phx-submit="save">
|
||||
<div class="grid gap-4">
|
||||
<.input
|
||||
field={@form[:vereinfacht_api_url]}
|
||||
type="text"
|
||||
label={gettext("API URL")}
|
||||
disabled={@vereinfacht_api_url_env_set}
|
||||
placeholder={
|
||||
if(@vereinfacht_api_url_env_set,
|
||||
do: gettext("From VEREINFACHT_API_URL"),
|
||||
else: "https://api.verein.visuel.dev/api/v1"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div class="form-control">
|
||||
<label class="label" for={@form[:vereinfacht_api_key].id}>
|
||||
<span class="label-text">{gettext("API Key")}</span>
|
||||
<%= if @vereinfacht_api_key_set do %>
|
||||
<span class="label-text-alt badge badge-ghost">{gettext("(set)")}</span>
|
||||
<% end %>
|
||||
</label>
|
||||
<.input
|
||||
field={@form[:vereinfacht_api_key]}
|
||||
type="password"
|
||||
label=""
|
||||
disabled={@vereinfacht_api_key_env_set}
|
||||
placeholder={
|
||||
if(@vereinfacht_api_key_env_set,
|
||||
do: gettext("From VEREINFACHT_API_KEY"),
|
||||
else:
|
||||
if(@vereinfacht_api_key_set,
|
||||
do: gettext("Leave blank to keep current"),
|
||||
else: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<.input
|
||||
field={@form[:vereinfacht_club_id]}
|
||||
type="text"
|
||||
label={gettext("Club ID")}
|
||||
disabled={@vereinfacht_club_id_env_set}
|
||||
placeholder={
|
||||
if(@vereinfacht_club_id_env_set, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2")
|
||||
}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:vereinfacht_app_url]}
|
||||
type="text"
|
||||
label={gettext("App URL (contact view link)")}
|
||||
disabled={@vereinfacht_app_url_env_set}
|
||||
placeholder={
|
||||
if(@vereinfacht_app_url_env_set,
|
||||
do: gettext("From VEREINFACHT_APP_URL"),
|
||||
else: "https://app.verein.visuel.dev"
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<.button
|
||||
:if={
|
||||
not (@vereinfacht_api_url_env_set and @vereinfacht_api_key_env_set and
|
||||
@vereinfacht_club_id_env_set)
|
||||
}
|
||||
phx-disable-with={gettext("Saving...")}
|
||||
variant="primary"
|
||||
class="mt-2"
|
||||
>
|
||||
{gettext("Save Vereinfacht Settings")}
|
||||
</.button>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<.button
|
||||
:if={Mv.Config.vereinfacht_configured?()}
|
||||
type="button"
|
||||
phx-click="test_vereinfacht_connection"
|
||||
phx-disable-with={gettext("Testing...")}
|
||||
class="btn-outline"
|
||||
>
|
||||
{gettext("Test Integration")}
|
||||
</.button>
|
||||
<.button
|
||||
:if={Mv.Config.vereinfacht_configured?()}
|
||||
type="button"
|
||||
phx-click="sync_vereinfacht_contacts"
|
||||
phx-disable-with={gettext("Syncing...")}
|
||||
class="btn-outline"
|
||||
>
|
||||
{gettext("Sync all members without Vereinfacht contact")}
|
||||
</.button>
|
||||
</div>
|
||||
<%= if @vereinfacht_test_result do %>
|
||||
<.vereinfacht_test_result result={@vereinfacht_test_result} />
|
||||
<% end %>
|
||||
<%= if @last_vereinfacht_sync_result do %>
|
||||
<.vereinfacht_sync_result result={@last_vereinfacht_sync_result} />
|
||||
<% end %>
|
||||
</.form>
|
||||
</.form_section>
|
||||
<%!-- OIDC Section --%>
|
||||
<.form_section title={gettext("OIDC")}>
|
||||
<%= if @oidc_env_configured do %>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
{gettext("Some values are set via environment variables. Those fields are read-only.")}
|
||||
</p>
|
||||
<% end %>
|
||||
<.form for={@form} id="oidc-form" phx-change="validate" phx-submit="save">
|
||||
<div class="grid gap-4">
|
||||
<.input
|
||||
field={@form[:oidc_client_id]}
|
||||
type="text"
|
||||
label={gettext("Client ID")}
|
||||
disabled={@oidc_client_id_env_set}
|
||||
placeholder={
|
||||
if(@oidc_client_id_env_set, do: gettext("From OIDC_CLIENT_ID"), else: "mv")
|
||||
}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:oidc_base_url]}
|
||||
type="text"
|
||||
label={gettext("Base URL")}
|
||||
disabled={@oidc_base_url_env_set}
|
||||
placeholder={
|
||||
if(@oidc_base_url_env_set,
|
||||
do: gettext("From OIDC_BASE_URL"),
|
||||
else: "http://localhost:8080/auth/v1"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:oidc_redirect_uri]}
|
||||
type="text"
|
||||
label={gettext("Redirect URI")}
|
||||
disabled={@oidc_redirect_uri_env_set}
|
||||
placeholder={
|
||||
if(@oidc_redirect_uri_env_set,
|
||||
do: gettext("From OIDC_REDIRECT_URI"),
|
||||
else: "http://localhost:4000/auth/user/oidc/callback"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div class="form-control">
|
||||
<label class="label" for={@form[:oidc_client_secret].id}>
|
||||
<span class="label-text">{gettext("Client Secret")}</span>
|
||||
<%= if @oidc_client_secret_set do %>
|
||||
<span class="label-text-alt badge badge-ghost">{gettext("(set)")}</span>
|
||||
<% end %>
|
||||
</label>
|
||||
<.input
|
||||
field={@form[:oidc_client_secret]}
|
||||
type="password"
|
||||
label=""
|
||||
disabled={@oidc_client_secret_env_set}
|
||||
placeholder={
|
||||
if(@oidc_client_secret_env_set,
|
||||
do: gettext("From OIDC_CLIENT_SECRET"),
|
||||
else:
|
||||
if(@oidc_client_secret_set,
|
||||
do: gettext("Leave blank to keep current"),
|
||||
else: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<.input
|
||||
field={@form[:oidc_admin_group_name]}
|
||||
type="text"
|
||||
label={gettext("Admin group name")}
|
||||
disabled={@oidc_admin_group_name_env_set}
|
||||
placeholder={
|
||||
if(@oidc_admin_group_name_env_set,
|
||||
do: gettext("From OIDC_ADMIN_GROUP_NAME"),
|
||||
else: gettext("e.g. admin")
|
||||
)
|
||||
}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:oidc_groups_claim]}
|
||||
type="text"
|
||||
label={gettext("Groups claim")}
|
||||
disabled={@oidc_groups_claim_env_set}
|
||||
placeholder={
|
||||
if(@oidc_groups_claim_env_set,
|
||||
do: gettext("From OIDC_GROUPS_CLAIM"),
|
||||
else: "groups"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-2">
|
||||
<.input
|
||||
field={@form[:oidc_only]}
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
disabled={@oidc_only_env_set or not @oidc_configured}
|
||||
/>
|
||||
<span class="label-text">
|
||||
{gettext("Only OIDC sign-in (hide password login)")}
|
||||
<%= if @oidc_only_env_set do %>
|
||||
<span class="label-text-alt text-base-content/70 ml-1">
|
||||
({gettext("From OIDC_ONLY")})
|
||||
</span>
|
||||
<% end %>
|
||||
</span>
|
||||
</label>
|
||||
<p class="label-text-alt text-base-content/70 mt-1">
|
||||
{gettext(
|
||||
"When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<.button
|
||||
:if={
|
||||
not (@oidc_client_id_env_set and @oidc_base_url_env_set and
|
||||
@oidc_redirect_uri_env_set and @oidc_client_secret_env_set and
|
||||
@oidc_admin_group_name_env_set and @oidc_groups_claim_env_set and
|
||||
@oidc_only_env_set)
|
||||
}
|
||||
phx-disable-with={gettext("Saving...")}
|
||||
variant="primary"
|
||||
class="mt-2"
|
||||
>
|
||||
{gettext("Save OIDC Settings")}
|
||||
</.button>
|
||||
</.form>
|
||||
</.form_section>
|
||||
</Layouts.app>
|
||||
"""
|
||||
|
|
@ -100,18 +344,71 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("test_vereinfacht_connection", _params, socket) do
|
||||
result = Mv.Vereinfacht.test_connection()
|
||||
{:noreply, assign(socket, :vereinfacht_test_result, result)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("sync_vereinfacht_contacts", _params, socket) do
|
||||
case Mv.Vereinfacht.sync_members_without_contact() do
|
||||
{:ok, %{synced: synced, errors: errors}} ->
|
||||
errors_with_names = enrich_sync_errors(errors)
|
||||
result = %{synced: synced, errors: errors_with_names}
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:last_vereinfacht_sync_result, result)
|
||||
|> put_flash(
|
||||
:info,
|
||||
if(errors_with_names == [],
|
||||
do: gettext("Synced %{count} member(s) to Vereinfacht.", count: synced),
|
||||
else:
|
||||
gettext("Synced %{count} member(s). %{error_count} failed.",
|
||||
count: synced,
|
||||
error_count: length(errors_with_names)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, :not_configured} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Vereinfacht is not configured. Set API URL, API Key, and Club ID.")
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"setting" => setting_params}, socket) do
|
||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||
# Never send blank API key / client secret so we do not overwrite stored secrets
|
||||
setting_params_clean =
|
||||
setting_params
|
||||
|> drop_blank_vereinfacht_api_key()
|
||||
|> drop_blank_oidc_client_secret()
|
||||
|
||||
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params, actor) do
|
||||
saves_vereinfacht = vereinfacht_params?(setting_params_clean)
|
||||
|
||||
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do
|
||||
{:ok, _updated_settings} ->
|
||||
# Reload settings from database to ensure all dependent data is updated
|
||||
{:ok, fresh_settings} = Membership.get_settings()
|
||||
|
||||
test_result =
|
||||
if saves_vereinfacht, do: Mv.Vereinfacht.test_connection(), else: nil
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:settings, fresh_settings)
|
||||
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|
||||
|> assign(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret))
|
||||
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|
||||
|> assign(:vereinfacht_test_result, test_result)
|
||||
|> put_flash(:info, gettext("Settings updated successfully"))
|
||||
|> assign_form()
|
||||
|
||||
|
|
@ -122,89 +419,48 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
|
||||
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
||||
id: "custom-fields-component",
|
||||
show_form: false
|
||||
)
|
||||
@vereinfacht_param_keys ~w[vereinfacht_api_url vereinfacht_api_key vereinfacht_club_id vereinfacht_app_url]
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:active_editing_section, nil)
|
||||
|> put_flash(:info, gettext("Data field %{action} successfully", action: action))}
|
||||
defp vereinfacht_params?(params) when is_map(params) do
|
||||
Enum.any?(@vereinfacht_param_keys, &Map.has_key?(params, &1))
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_deleted, _custom_field}, socket) do
|
||||
{:noreply, put_flash(socket, :info, gettext("Data field deleted successfully"))}
|
||||
defp drop_blank_vereinfacht_api_key(params) when is_map(params) do
|
||||
case params do
|
||||
%{"vereinfacht_api_key" => v} when v in [nil, ""] ->
|
||||
Map.delete(params, "vereinfacht_api_key")
|
||||
|
||||
_ ->
|
||||
params
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_delete_error, error}, socket) do
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Failed to delete data field: %{error}", error: inspect(error))
|
||||
)}
|
||||
end
|
||||
defp drop_blank_oidc_client_secret(params) when is_map(params) do
|
||||
case params do
|
||||
%{"oidc_client_secret" => v} when v in [nil, ""] ->
|
||||
Map.delete(params, "oidc_client_secret")
|
||||
|
||||
@impl true
|
||||
def handle_info(:custom_field_slug_mismatch, socket) do
|
||||
{:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))}
|
||||
end
|
||||
|
||||
def handle_info({:custom_fields_load_error, _error}, socket) do
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Could not load data fields. Please check your permissions.")
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:editing_section_changed, section}, socket) do
|
||||
{:noreply, assign(socket, :active_editing_section, section)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:member_field_saved, _member_field, action}, socket) do
|
||||
# Reload settings to get updated member_field_visibility
|
||||
{:ok, updated_settings} = Membership.get_settings()
|
||||
|
||||
# Send update to member fields component to close form
|
||||
send_update(MvWeb.MemberFieldLive.IndexComponent,
|
||||
id: "member-fields-component",
|
||||
show_form: false,
|
||||
settings: updated_settings
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:settings, updated_settings)
|
||||
|> assign(:active_editing_section, nil)
|
||||
|> put_flash(:info, gettext("Member field %{action} successfully", action: action))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:member_field_visibility_updated}, socket) do
|
||||
# Legacy event - reload settings and update component
|
||||
{:ok, updated_settings} = Membership.get_settings()
|
||||
|
||||
send_update(MvWeb.MemberFieldLive.IndexComponent,
|
||||
id: "member-fields-component",
|
||||
settings: updated_settings
|
||||
)
|
||||
|
||||
{:noreply, assign(socket, :settings, updated_settings)}
|
||||
_ ->
|
||||
params
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||
# Show ENV values in disabled fields (Vereinfacht and OIDC); never expose API key / client secret
|
||||
settings_display =
|
||||
settings
|
||||
|> merge_vereinfacht_env_values()
|
||||
|> merge_oidc_env_values()
|
||||
|
||||
settings_for_form = %{
|
||||
settings_display
|
||||
| vereinfacht_api_key: nil,
|
||||
oidc_client_secret: nil
|
||||
}
|
||||
|
||||
form =
|
||||
AshPhoenix.Form.for_update(
|
||||
settings,
|
||||
settings_for_form,
|
||||
:update,
|
||||
api: Membership,
|
||||
as: "setting",
|
||||
|
|
@ -213,4 +469,237 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|
||||
assign(socket, form: to_form(form))
|
||||
end
|
||||
|
||||
defp put_if_env_set(map, _key, false, _value), do: map
|
||||
defp put_if_env_set(map, key, true, value), do: Map.put(map, key, value)
|
||||
|
||||
defp merge_vereinfacht_env_values(s) do
|
||||
s
|
||||
|> put_if_env_set(
|
||||
:vereinfacht_api_url,
|
||||
Mv.Config.vereinfacht_api_url_env_set?(),
|
||||
Mv.Config.vereinfacht_api_url()
|
||||
)
|
||||
|> put_if_env_set(
|
||||
:vereinfacht_club_id,
|
||||
Mv.Config.vereinfacht_club_id_env_set?(),
|
||||
Mv.Config.vereinfacht_club_id()
|
||||
)
|
||||
|> put_if_env_set(
|
||||
:vereinfacht_app_url,
|
||||
Mv.Config.vereinfacht_app_url_env_set?(),
|
||||
Mv.Config.vereinfacht_app_url()
|
||||
)
|
||||
end
|
||||
|
||||
defp merge_oidc_env_values(s) do
|
||||
s
|
||||
|> put_if_env_set(
|
||||
:oidc_client_id,
|
||||
Mv.Config.oidc_client_id_env_set?(),
|
||||
Mv.Config.oidc_client_id()
|
||||
)
|
||||
|> put_if_env_set(
|
||||
:oidc_base_url,
|
||||
Mv.Config.oidc_base_url_env_set?(),
|
||||
Mv.Config.oidc_base_url()
|
||||
)
|
||||
|> put_if_env_set(
|
||||
:oidc_redirect_uri,
|
||||
Mv.Config.oidc_redirect_uri_env_set?(),
|
||||
Mv.Config.oidc_redirect_uri()
|
||||
)
|
||||
|> put_if_env_set(
|
||||
:oidc_admin_group_name,
|
||||
Mv.Config.oidc_admin_group_name_env_set?(),
|
||||
Mv.Config.oidc_admin_group_name()
|
||||
)
|
||||
|> put_if_env_set(
|
||||
:oidc_groups_claim,
|
||||
Mv.Config.oidc_groups_claim_env_set?(),
|
||||
Mv.Config.oidc_groups_claim()
|
||||
)
|
||||
|> put_if_oidc_only_env_set()
|
||||
end
|
||||
|
||||
defp put_if_oidc_only_env_set(s) do
|
||||
if Mv.Config.oidc_only_env_set?() do
|
||||
Map.put(s, :oidc_only, Mv.Config.oidc_only?())
|
||||
else
|
||||
s
|
||||
end
|
||||
end
|
||||
|
||||
defp enrich_sync_errors([]), do: []
|
||||
|
||||
defp enrich_sync_errors(errors) when is_list(errors) do
|
||||
name_by_id = fetch_member_names_by_ids(Enum.map(errors, fn {id, _} -> id end))
|
||||
|
||||
Enum.map(errors, fn {member_id, reason} ->
|
||||
%{
|
||||
member_id: member_id,
|
||||
member_name: Map.get(name_by_id, member_id) || to_string(member_id),
|
||||
message: Mv.Vereinfacht.format_error(reason),
|
||||
detail: extract_vereinfacht_detail(reason)
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp fetch_member_names_by_ids(ids) do
|
||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
opts = Mv.Helpers.ash_actor_opts(actor)
|
||||
query = Ash.Query.filter(Mv.Membership.Member, expr(id in ^ids))
|
||||
|
||||
case Ash.read(query, opts) do
|
||||
{:ok, members} ->
|
||||
Map.new(members, fn m -> {m.id, MvWeb.Helpers.MemberHelpers.display_name(m)} end)
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_vereinfacht_detail({:http, _status, detail}) when is_binary(detail), do: detail
|
||||
defp extract_vereinfacht_detail(_), do: nil
|
||||
|
||||
defp translate_vereinfacht_message(%{detail: detail}) when is_binary(detail) do
|
||||
gettext("Vereinfacht: %{detail}",
|
||||
detail: Gettext.dgettext(MvWeb.Gettext, "default", detail)
|
||||
)
|
||||
end
|
||||
|
||||
defp translate_vereinfacht_message(%{message: message}) do
|
||||
Gettext.dgettext(MvWeb.Gettext, "default", message)
|
||||
end
|
||||
|
||||
attr :result, :any, required: true
|
||||
|
||||
defp vereinfacht_test_result(%{result: {:ok, :connected}} = assigns) do
|
||||
~H"""
|
||||
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-success bg-success/10 text-success-aa text-sm">
|
||||
<.icon name="hero-check-circle" class="size-5 shrink-0" />
|
||||
<span>{gettext("Connection successful. API URL, API Key and Club ID are valid.")}</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp vereinfacht_test_result(%{result: {:error, :not_configured}} = assigns) do
|
||||
~H"""
|
||||
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-warning bg-warning/10 text-warning-aa text-sm">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
|
||||
<span>{gettext("Not configured. Please set API URL, API Key and Club ID.")}</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp vereinfacht_test_result(%{result: {:error, {:http, _status, :html_response}}} = assigns) do
|
||||
~H"""
|
||||
<div class="mt-3 flex items-start gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{gettext(
|
||||
"Connection failed. The URL does not point to a Vereinfacht API (received HTML instead of JSON)."
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp vereinfacht_test_result(%{result: {:error, {:http, 401, _}}} = assigns) do
|
||||
~H"""
|
||||
<div class="mt-3 flex items-start gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" />
|
||||
<span>{gettext("Connection failed (HTTP 401): API key is invalid or missing.")}</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp vereinfacht_test_result(%{result: {:error, {:http, 403, _}}} = assigns) do
|
||||
~H"""
|
||||
<div class="mt-3 flex items-start gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{gettext(
|
||||
"Connection failed (HTTP 403): Access denied. Please check the Club ID and API key permissions."
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp vereinfacht_test_result(%{result: {:error, {:http, 404, _}}} = assigns) do
|
||||
~H"""
|
||||
<div class="mt-3 flex items-start gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{gettext(
|
||||
"Connection failed (HTTP 404): API endpoint not found. Please check the API URL (e.g. correct version path)."
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp vereinfacht_test_result(%{result: {:error, {:http, status, message}}} = assigns) do
|
||||
assigns = assign(assigns, :status, status)
|
||||
assigns = assign(assigns, :message, message)
|
||||
|
||||
~H"""
|
||||
<div class="mt-3 flex items-start gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{gettext("Connection failed (HTTP %{status}):", status: @status)}
|
||||
<span class="ml-1">{@message}</span>
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp vereinfacht_test_result(%{result: {:error, {:request_failed, _reason}}} = assigns) do
|
||||
~H"""
|
||||
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
||||
<span>
|
||||
{gettext("Connection failed. Could not reach the API (network error or wrong URL).")}
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp vereinfacht_test_result(%{result: {:error, _}} = assigns) do
|
||||
~H"""
|
||||
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
||||
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
||||
<span>{gettext("Connection failed. Unknown error.")}</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :result, :map, required: true
|
||||
|
||||
defp vereinfacht_sync_result(assigns) do
|
||||
~H"""
|
||||
<div class="mt-4 p-4 rounded-lg border border-base-300 bg-base-200 space-y-2">
|
||||
<p class="font-medium">
|
||||
{gettext("Last sync result:")}
|
||||
<span class="text-success-aa ml-1">{gettext("%{count} synced", count: @result.synced)}</span>
|
||||
<%= if @result.errors != [] do %>
|
||||
<span class="text-error-aa ml-1">
|
||||
{gettext("%{count} failed", count: length(@result.errors))}
|
||||
</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<%= if @result.errors != [] do %>
|
||||
<p class="text-sm text-base-content/70 mt-2">{gettext("Failed members:")}</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 max-h-48 overflow-y-auto">
|
||||
<%= for err <- @result.errors do %>
|
||||
<li>
|
||||
<span class="font-medium">{err.member_name}</span>: {translate_vereinfacht_message(err)}
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -17,10 +17,12 @@ defmodule MvWeb.GroupLive.Show do
|
|||
|
||||
require Logger
|
||||
|
||||
import Ash.Expr
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||
import MvWeb.Authorization
|
||||
|
||||
alias Mv.Membership
|
||||
alias MvWeb.Helpers.MemberHelpers, as: MemberHelpers
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
|
|
@ -29,6 +31,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
|> assign(:show_add_member_input, false)
|
||||
|> assign(:member_search_query, "")
|
||||
|> assign(:available_members, [])
|
||||
|> assign(:add_member_candidates, [])
|
||||
|> assign(:selected_member_ids, [])
|
||||
|> assign(:selected_members, [])
|
||||
|> assign(:show_member_dropdown, false)
|
||||
|
|
@ -94,13 +97,21 @@ defmodule MvWeb.GroupLive.Show do
|
|||
</h1>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
|
||||
<.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"}>
|
||||
<%= if can?(@current_user, :update, @group) do %>
|
||||
<.button
|
||||
variant="primary"
|
||||
navigate={~p"/groups/#{@group.slug}/edit"}
|
||||
data-testid="group-show-edit-btn"
|
||||
>
|
||||
{gettext("Edit")}
|
||||
</.button>
|
||||
<% end %>
|
||||
<%= if can?(@current_user, :destroy, Mv.Membership.Group) do %>
|
||||
<.button class="btn-error" phx-click="open_delete_modal">
|
||||
<%= if can?(@current_user, :destroy, @group) do %>
|
||||
<.button
|
||||
class="btn-error"
|
||||
phx-click="open_delete_modal"
|
||||
data-testid="group-show-delete-btn"
|
||||
>
|
||||
{gettext("Delete")}
|
||||
</.button>
|
||||
<% end %>
|
||||
|
|
@ -123,7 +134,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
<div>
|
||||
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2>
|
||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||
<p class="mb-4">
|
||||
<p class="mb-4" data-testid="group-show-member-count">
|
||||
{ngettext(
|
||||
"Total: %{count} member",
|
||||
"Total: %{count} members",
|
||||
|
|
@ -132,7 +143,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
)}
|
||||
</p>
|
||||
|
||||
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
|
||||
<%= if can?(@current_user, :update, @group) do %>
|
||||
<div class="mb-4">
|
||||
<%= if assigns[:show_add_member_input] do %>
|
||||
<div class="join w-full">
|
||||
|
|
@ -160,6 +171,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
<input
|
||||
type="text"
|
||||
id="member-search-input"
|
||||
data-testid="group-show-member-search-input"
|
||||
role="combobox"
|
||||
phx-hook="ComboBox"
|
||||
phx-focus="show_member_dropdown"
|
||||
|
|
@ -228,6 +240,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
type="button"
|
||||
class="btn btn-primary join-item"
|
||||
phx-click="add_selected_members"
|
||||
data-testid="group-show-add-selected-members-btn"
|
||||
disabled={Enum.empty?(@selected_member_ids)}
|
||||
aria-label={gettext("Add members")}
|
||||
>
|
||||
|
|
@ -255,15 +268,17 @@ defmodule MvWeb.GroupLive.Show do
|
|||
<% end %>
|
||||
|
||||
<%= if Enum.empty?(@group.members || []) do %>
|
||||
<p class="text-base-content/50 italic">{gettext("No members in this group")}</p>
|
||||
<p class="text-base-content/50 italic" data-testid="group-show-no-members">
|
||||
{gettext("No members in this group")}
|
||||
</p>
|
||||
<% else %>
|
||||
<div class="overflow-x-auto">
|
||||
<div class="overflow-x-auto" data-testid="group-show-members-table">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{gettext("Name")}</th>
|
||||
<th>{gettext("Email")}</th>
|
||||
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
|
||||
<%= if can?(@current_user, :update, @group) do %>
|
||||
<th class="w-0">{gettext("Actions")}</th>
|
||||
<% end %>
|
||||
</tr>
|
||||
|
|
@ -291,13 +306,14 @@ defmodule MvWeb.GroupLive.Show do
|
|||
<span class="text-base-content/50 italic">—</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
|
||||
<%= if can?(@current_user, :update, @group) do %>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm text-error"
|
||||
phx-click="remove_member"
|
||||
phx-value-member_id={member.id}
|
||||
data-testid="group-show-remove-member"
|
||||
aria-label={gettext("Remove member from group")}
|
||||
data-tooltip={gettext("Remove")}
|
||||
>
|
||||
|
|
@ -431,28 +447,31 @@ defmodule MvWeb.GroupLive.Show do
|
|||
# Add Member Events
|
||||
@impl true
|
||||
def handle_event("show_add_member_input", _params, socket) do
|
||||
# Reload group to ensure we have the latest members list
|
||||
actor = current_actor(socket)
|
||||
group = socket.assigns.group
|
||||
socket = reload_group(socket, group.slug, actor)
|
||||
# Load candidate members once (single DB read). Search/focus then filter in memory (R2).
|
||||
socket =
|
||||
socket
|
||||
|> assign(:show_add_member_input, true)
|
||||
|> assign(:member_search_query, "")
|
||||
|> assign(:selected_member_ids, [])
|
||||
|> assign(:selected_members, [])
|
||||
|> assign(:show_member_dropdown, false)
|
||||
|> assign(:focused_member_index, nil)
|
||||
|> load_add_member_candidates()
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_add_member_input, true)
|
||||
|> assign(:member_search_query, "")
|
||||
|> assign(:available_members, [])
|
||||
|> assign(:selected_member_ids, [])
|
||||
|> assign(:selected_members, [])
|
||||
|> assign(:show_member_dropdown, false)
|
||||
|> assign(:focused_member_index, nil)}
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("show_member_dropdown", _params, socket) do
|
||||
# Use existing group.members for filtering; reload only on add/remove
|
||||
# Filter in memory from preloaded candidates; no DB read (R2).
|
||||
query = socket.assigns.member_search_query || ""
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> load_available_members("")
|
||||
|> assign(
|
||||
:available_members,
|
||||
filter_candidates_in_memory(socket.assigns.add_member_candidates, query)
|
||||
)
|
||||
|> assign(:show_member_dropdown, true)
|
||||
|> assign(:focused_member_index, nil)
|
||||
|
||||
|
|
@ -466,6 +485,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
|> assign(:show_add_member_input, false)
|
||||
|> assign(:member_search_query, "")
|
||||
|> assign(:available_members, [])
|
||||
|> assign(:add_member_candidates, [])
|
||||
|> assign(:selected_member_ids, [])
|
||||
|> assign(:selected_members, [])
|
||||
|> assign(:show_member_dropdown, false)
|
||||
|
|
@ -532,11 +552,13 @@ defmodule MvWeb.GroupLive.Show do
|
|||
|
||||
@impl true
|
||||
def handle_event("search_members", %{"member_search" => query}, socket) do
|
||||
# Use existing group.members for filtering; reload only on add/remove
|
||||
# Filter in memory from preloaded candidates; no DB read (R2).
|
||||
candidates = socket.assigns.add_member_candidates || []
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:member_search_query, query)
|
||||
|> load_available_members(query)
|
||||
|> assign(:available_members, filter_candidates_in_memory(candidates, query))
|
||||
|> assign(:show_member_dropdown, true)
|
||||
|> assign(:focused_member_index, nil)
|
||||
|
||||
|
|
@ -660,47 +682,69 @@ defmodule MvWeb.GroupLive.Show do
|
|||
end
|
||||
end
|
||||
|
||||
defp load_available_members(socket, query) do
|
||||
# Load candidate members once when opening add-member UI (single DB read).
|
||||
defp load_add_member_candidates(socket) do
|
||||
require Ash.Query
|
||||
|
||||
current_member_ids = group_member_ids_set(socket.assigns.group)
|
||||
base_query = available_members_base_query(query)
|
||||
|
||||
# Fetch more than 10, then exclude already-in-group and take 10 (avoids empty dropdown when first N are all in group)
|
||||
fetch_limit = 50
|
||||
limited_query = Ash.Query.limit(base_query, fetch_limit)
|
||||
group = socket.assigns.group
|
||||
exclude_ids = group_member_ids_set(group) |> MapSet.to_list()
|
||||
actor = current_actor(socket)
|
||||
|
||||
case Ash.read(limited_query, actor: actor, domain: Mv.Membership) do
|
||||
{:ok, members} ->
|
||||
available =
|
||||
members
|
||||
|> Enum.reject(fn m -> MapSet.member?(current_member_ids, m.id) end)
|
||||
|> Enum.take(10)
|
||||
if exclude_ids == [] do
|
||||
# No members in group; load first N members
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.sort([:last_name, :first_name])
|
||||
|> Ash.Query.limit(300)
|
||||
|
||||
assign(socket, available_members: available)
|
||||
do_load_add_member_candidates(socket, query, actor)
|
||||
else
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.filter(expr(id not in ^exclude_ids))
|
||||
|> Ash.Query.sort([:last_name, :first_name])
|
||||
|> Ash.Query.limit(300)
|
||||
|
||||
do_load_add_member_candidates(socket, query, actor)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_load_add_member_candidates(socket, query, actor) do
|
||||
case Ash.read(query, actor: actor, domain: Mv.Membership) do
|
||||
{:ok, candidates} ->
|
||||
socket
|
||||
|> assign(:add_member_candidates, candidates)
|
||||
|> assign(:available_members, Enum.take(candidates, 10))
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning("Failed to load available members for group: #{inspect(error)}")
|
||||
Logger.warning("Failed to load add-member candidates: #{inspect(error)}")
|
||||
|
||||
socket
|
||||
|> put_flash(:error, gettext("Could not load member search. Please try again."))
|
||||
|> put_flash(:error, gettext("Could not load member list. Please try again."))
|
||||
|> assign(:add_member_candidates, [])
|
||||
|> assign(:available_members, [])
|
||||
end
|
||||
end
|
||||
|
||||
defp available_members_base_query(query) do
|
||||
search_query = if query && String.trim(query) != "", do: String.trim(query), else: nil
|
||||
# Filter preloaded candidates by query string (name/email). No DB read. R2.
|
||||
defp filter_candidates_in_memory(candidates, query) when is_list(candidates) do
|
||||
q = if is_binary(query), do: String.trim(query) |> String.downcase(), else: ""
|
||||
|
||||
if search_query do
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.for_read(:search, %{query: search_query})
|
||||
if q == "" do
|
||||
candidates |> Enum.take(10)
|
||||
else
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.new()
|
||||
candidates
|
||||
|> Enum.filter(fn m ->
|
||||
name = MemberHelpers.display_name(m) |> String.downcase()
|
||||
email = (m.email || "") |> String.downcase()
|
||||
String.contains?(name, q) or String.contains?(email, q)
|
||||
end)
|
||||
|> Enum.take(10)
|
||||
end
|
||||
end
|
||||
|
||||
defp filter_candidates_in_memory(_, _), do: []
|
||||
|
||||
defp group_member_ids_set(group) do
|
||||
members = group.members || []
|
||||
members |> Enum.map(& &1.id) |> MapSet.new()
|
||||
|
|
@ -740,6 +784,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
|> assign(:show_add_member_input, false)
|
||||
|> assign(:member_search_query, "")
|
||||
|> assign(:available_members, [])
|
||||
|> assign(:add_member_candidates, [])
|
||||
|> assign(:selected_member_ids, [])
|
||||
|> assign(:selected_members, [])
|
||||
|> assign(:show_member_dropdown, false)
|
||||
|
|
|
|||
|
|
@ -92,14 +92,22 @@ defmodule MvWeb.ImportLive do
|
|||
<Layouts.app flash={@flash} current_user={@current_user} club_name={@club_name}>
|
||||
<%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %>
|
||||
<%!-- CSV Import Section --%>
|
||||
<.form_section title={gettext("Import Members (CSV)")}>
|
||||
<Components.custom_fields_notice {assigns} />
|
||||
<Components.template_links {assigns} />
|
||||
<Components.import_form {assigns} />
|
||||
<%= if @import_status == :running or @import_status == :done or @import_status == :error do %>
|
||||
<Components.import_progress {assigns} />
|
||||
<% end %>
|
||||
</.form_section>
|
||||
<div data-testid="import-page">
|
||||
<.header>
|
||||
{gettext("Import Members")}
|
||||
<:subtitle>
|
||||
{gettext("Import members from CSV files.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
<.form_section title={gettext("Choose CSV file")}>
|
||||
<Components.custom_fields_notice {assigns} />
|
||||
<Components.template_links {assigns} />
|
||||
<Components.import_form {assigns} />
|
||||
<%= if @import_status == :running or @import_status == :done or @import_status == :error do %>
|
||||
<Components.import_progress {assigns} />
|
||||
<% end %>
|
||||
</.form_section>
|
||||
</div>
|
||||
<% else %>
|
||||
<div role="alert" class="alert alert-error">
|
||||
<.icon name="hero-exclamation-circle" class="size-5" aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -20,12 +20,12 @@ defmodule MvWeb.ImportLive.Components do
|
|||
"""
|
||||
def custom_fields_notice(assigns) do
|
||||
~H"""
|
||||
<div role="note" class="alert alert-info mb-4">
|
||||
<div role="note" class="alert alert-info mb-4 w-xl">
|
||||
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
||||
<div>
|
||||
<p class="text-sm mb-2">
|
||||
{gettext(
|
||||
"Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
|
||||
"Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
|
||||
)}
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
|
|
@ -48,7 +48,7 @@ defmodule MvWeb.ImportLive.Components do
|
|||
def template_links(assigns) do
|
||||
~H"""
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-base-content/70 mb-2">
|
||||
<p class="mb-2">
|
||||
{gettext("Download CSV templates:")}
|
||||
</p>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
|
|
@ -88,22 +88,20 @@ defmodule MvWeb.ImportLive.Components do
|
|||
phx-submit="start_import"
|
||||
data-testid="csv-upload-form"
|
||||
>
|
||||
<div class="form-control">
|
||||
<label for="csv_file" class="label">
|
||||
<span class="label-text">
|
||||
{gettext("CSV File")}
|
||||
</span>
|
||||
<fieldset class="mb-2 fieldset w-md">
|
||||
<label for="csv_file">
|
||||
<span class="mb-1 label">{gettext("CSV File")}</span>
|
||||
</label>
|
||||
<.live_file_input
|
||||
upload={@uploads.csv_file}
|
||||
id="csv_file"
|
||||
class="file-input file-input-bordered w-full"
|
||||
class="file-input file-input-bordered"
|
||||
aria-describedby="csv_file_help"
|
||||
/>
|
||||
<p class="label-text-alt mt-1" id="csv_file_help">
|
||||
<p class="text-sm text-base-content/60 mt-2" id="csv_file_help">
|
||||
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<.button
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
|||
- `on_cancel` - Callback function to call when form is cancelled
|
||||
|
||||
## Note
|
||||
Member fields are technical fields that cannot be changed (name, value_type, description, required).
|
||||
Only the visibility (show_in_overview) can be modified.
|
||||
Member fields are technical fields that cannot be changed (name, value_type).
|
||||
Visibility (show_in_overview) and required flag are stored in Settings and can be modified.
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
|
||||
|
|
@ -27,14 +27,13 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
|||
alias MvWeb.Helpers.FieldTypeFormatter
|
||||
alias MvWeb.Translations.MemberFields
|
||||
|
||||
@required_fields [:first_name, :last_name, :email]
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:field_attributes, get_field_attributes(assigns.member_field))
|
||||
|> assign(:is_email_field?, assigns.member_field == :email)
|
||||
|> assign(:vereinfacht_required_field?, vereinfacht_required_field?(assigns))
|
||||
|> assign(:field_label, MemberFields.label(assigns.member_field))
|
||||
|
||||
~H"""
|
||||
|
|
@ -117,89 +116,64 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
|||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@is_email_field?}
|
||||
class="tooltip tooltip-right"
|
||||
data-tip={gettext("This is a technical field and cannot be changed")}
|
||||
aria-label={gettext("This is a technical field and cannot be changed")}
|
||||
>
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<span class="mb-1 label flex items-center gap-2">
|
||||
{gettext("Description")}
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="w-4 h-4 text-base-content/60 cursor-help"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name={@form[:description].name}
|
||||
id={@form[:description].id}
|
||||
value={@form[:description].value}
|
||||
disabled
|
||||
readonly
|
||||
class="w-full input"
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
<.input
|
||||
:if={not @is_email_field?}
|
||||
field={@form[:description]}
|
||||
type="text"
|
||||
label={gettext("Description")}
|
||||
disabled={@is_email_field?}
|
||||
readonly={@is_email_field?}
|
||||
/>
|
||||
|
||||
<div
|
||||
:if={@is_email_field?}
|
||||
class="tooltip tooltip-right"
|
||||
data-tip={gettext("This is a technical field and cannot be changed")}
|
||||
aria-label={gettext("This is a technical field and cannot be changed")}
|
||||
>
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<input type="hidden" name={@form[:required].name} value="false" disabled />
|
||||
<span class="label flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={@form[:required].name}
|
||||
id={@form[:required].id}
|
||||
value="true"
|
||||
checked={@form[:required].value}
|
||||
disabled
|
||||
readonly
|
||||
class="checkbox checkbox-sm"
|
||||
/>
|
||||
<span class="flex items-center gap-2">
|
||||
{gettext("Required")}
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="w-4 h-4 text-base-content/60 cursor-help"
|
||||
aria-hidden="true"
|
||||
<%!-- Line break before Required / Show in overview block --%>
|
||||
<div class="mt-4">
|
||||
<%!-- Required: disabled for email (always required) or Vereinfacht-required fields when integration is active --%>
|
||||
<div
|
||||
:if={@is_email_field? or @vereinfacht_required_field?}
|
||||
class="tooltip tooltip-right"
|
||||
data-tip={
|
||||
if(@is_email_field?,
|
||||
do: gettext("This is a technical field and cannot be changed"),
|
||||
else: gettext("Required for Vereinfacht integration and cannot be disabled.")
|
||||
)
|
||||
}
|
||||
aria-label={
|
||||
if(@is_email_field?,
|
||||
do: gettext("This is a technical field and cannot be changed"),
|
||||
else: gettext("Required for Vereinfacht integration and cannot be disabled.")
|
||||
)
|
||||
}
|
||||
>
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<input type="hidden" name={@form[:required].name} value="true" />
|
||||
<span class="label flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={@form[:required].name}
|
||||
id={@form[:required].id}
|
||||
value="true"
|
||||
checked={@form[:required].value}
|
||||
disabled
|
||||
readonly
|
||||
class="checkbox checkbox-sm"
|
||||
/>
|
||||
<span class="flex items-center gap-2">
|
||||
{gettext("Required")}
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="w-4 h-4 text-base-content/60 cursor-help"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
<.input
|
||||
:if={not @is_email_field?}
|
||||
field={@form[:required]}
|
||||
type="checkbox"
|
||||
label={gettext("Required")}
|
||||
disabled={@is_email_field?}
|
||||
readonly={@is_email_field?}
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
<.input
|
||||
:if={not @is_email_field? and not @vereinfacht_required_field?}
|
||||
field={@form[:required]}
|
||||
type="checkbox"
|
||||
label={gettext("Required")}
|
||||
/>
|
||||
|
||||
<.input
|
||||
field={@form[:show_in_overview]}
|
||||
type="checkbox"
|
||||
label={gettext("Show in overview")}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:show_in_overview]}
|
||||
type="checkbox"
|
||||
label={gettext("Show in overview")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="justify-end mt-4 card-actions">
|
||||
<.button type="button" phx-click="cancel" phx-target={@myself}>
|
||||
|
|
@ -225,24 +199,35 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
|||
|
||||
@impl true
|
||||
def handle_event("validate", %{"member_field" => member_field_params}, socket) do
|
||||
# For member fields, we only validate show_in_overview
|
||||
# Other fields are read-only or derived from the Member Resource
|
||||
form = socket.assigns.form
|
||||
|
||||
updated_params =
|
||||
member_field_params
|
||||
|> Map.put(
|
||||
"show_in_overview",
|
||||
# Unchecked checkboxes are not in params; preserve current form value when key is missing
|
||||
show_in_overview =
|
||||
if Map.has_key?(member_field_params, "show_in_overview") do
|
||||
TypeParsers.parse_boolean(member_field_params["show_in_overview"])
|
||||
)
|
||||
|> Map.put("name", form.source["name"])
|
||||
|> Map.put("value_type", form.source["value_type"])
|
||||
|> Map.put("description", form.source["description"])
|
||||
|> Map.put("required", form.source["required"])
|
||||
else
|
||||
form.source["show_in_overview"]
|
||||
end
|
||||
|
||||
required =
|
||||
socket.assigns.vereinfacht_required_field? ||
|
||||
if Map.has_key?(member_field_params, "required") do
|
||||
TypeParsers.parse_boolean(member_field_params["required"])
|
||||
else
|
||||
form.source["required"]
|
||||
end
|
||||
|
||||
# Merge so we keep name/value_type and have current checkbox state; use as new form source
|
||||
merged_source =
|
||||
form.source
|
||||
|> Map.merge(%{
|
||||
"show_in_overview" => show_in_overview,
|
||||
"required" => required,
|
||||
"name" => form.source["name"],
|
||||
"value_type" => form.source["value_type"]
|
||||
})
|
||||
|
||||
updated_form =
|
||||
form
|
||||
|> Map.put(:value, updated_params)
|
||||
to_form(merged_source, as: "member_field")
|
||||
|> Map.put(:errors, [])
|
||||
|
||||
{:noreply, assign(socket, form: updated_form)}
|
||||
|
|
@ -250,23 +235,36 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
|||
|
||||
@impl true
|
||||
def handle_event("save", %{"member_field" => member_field_params}, socket) do
|
||||
# Only show_in_overview can be changed for member fields
|
||||
show_in_overview = TypeParsers.parse_boolean(member_field_params["show_in_overview"])
|
||||
form = socket.assigns.form
|
||||
# Unchecked checkboxes are not in submit params; use form source when key missing
|
||||
show_in_overview =
|
||||
if Map.has_key?(member_field_params, "show_in_overview") do
|
||||
TypeParsers.parse_boolean(member_field_params["show_in_overview"])
|
||||
else
|
||||
form.source["show_in_overview"]
|
||||
end
|
||||
|
||||
required =
|
||||
socket.assigns.vereinfacht_required_field? ||
|
||||
if Map.has_key?(member_field_params, "required") do
|
||||
TypeParsers.parse_boolean(member_field_params["required"])
|
||||
else
|
||||
form.source["required"]
|
||||
end
|
||||
|
||||
field_string = Atom.to_string(socket.assigns.member_field)
|
||||
|
||||
# Use atomic action to update only this single field
|
||||
# This prevents lost updates in concurrent scenarios
|
||||
case Membership.update_single_member_field_visibility(
|
||||
case Membership.update_single_member_field(
|
||||
socket.assigns.settings,
|
||||
field: field_string,
|
||||
show_in_overview: show_in_overview
|
||||
show_in_overview: show_in_overview,
|
||||
required: required
|
||||
) do
|
||||
{:ok, _updated_settings} ->
|
||||
socket.assigns.on_save.(socket.assigns.member_field, "update")
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, error} ->
|
||||
# Add error to form
|
||||
form =
|
||||
socket.assigns.form
|
||||
|> Map.put(:errors, [
|
||||
|
|
@ -288,16 +286,29 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
|||
defp assign_form(%{assigns: %{member_field: member_field, settings: settings}} = socket) do
|
||||
field_attributes = get_field_attributes(member_field)
|
||||
visibility_config = settings.member_field_visibility || %{}
|
||||
normalized_config = VisibilityConfig.normalize(visibility_config)
|
||||
show_in_overview = Map.get(normalized_config, member_field, true)
|
||||
required_config = settings.member_field_required || %{}
|
||||
normalized_visibility = VisibilityConfig.normalize(visibility_config)
|
||||
normalized_required = VisibilityConfig.normalize(required_config)
|
||||
show_in_overview = Map.get(normalized_visibility, member_field, true)
|
||||
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
|
||||
# Persist in socket so validate/save can enforce server-side without relying on render assigns
|
||||
socket =
|
||||
assign(
|
||||
socket,
|
||||
:vereinfacht_required_field?,
|
||||
vereinfacht_required_field?(%{member_field: member_field})
|
||||
)
|
||||
|
||||
# Email always required; Vereinfacht-required fields when integration active; else from settings
|
||||
required =
|
||||
member_field == :email ||
|
||||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(member_field)) ||
|
||||
Map.get(normalized_required, member_field, false)
|
||||
|
||||
# Create a manual form structure with string keys
|
||||
# Note: immutable is not included as it's not editable for member fields
|
||||
form_data = %{
|
||||
"name" => MemberFields.label(member_field),
|
||||
"value_type" => FieldTypeFormatter.format(field_attributes.value_type),
|
||||
"description" => field_attributes.description || "",
|
||||
"required" => field_attributes.required,
|
||||
"required" => required,
|
||||
"show_in_overview" => show_in_overview
|
||||
}
|
||||
|
||||
|
|
@ -307,24 +318,14 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
|||
end
|
||||
|
||||
defp get_field_attributes(field) when is_atom(field) do
|
||||
# Get attribute info from Member Resource
|
||||
alias Ash.Resource.Info
|
||||
|
||||
case Info.attribute(Mv.Membership.Member, field) do
|
||||
nil ->
|
||||
# Fallback for fields not in resource (shouldn't happen with Constants)
|
||||
%{
|
||||
value_type: :string,
|
||||
description: nil,
|
||||
required: field in @required_fields
|
||||
}
|
||||
%{value_type: :string}
|
||||
|
||||
attribute ->
|
||||
%{
|
||||
value_type: attribute.type,
|
||||
description: nil,
|
||||
required: not attribute.allow_nil?
|
||||
}
|
||||
%{value_type: attribute.type}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -335,4 +336,9 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
|||
defp format_error(error) do
|
||||
inspect(error)
|
||||
end
|
||||
|
||||
defp vereinfacht_required_field?(assigns) do
|
||||
Mv.Config.vereinfacht_configured?() &&
|
||||
Mv.Constants.vereinfacht_required_field?(assigns.member_field)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
assigns =
|
||||
assigns
|
||||
|> assign(:member_fields, get_member_fields_with_visibility(assigns.settings))
|
||||
|> assign(:required?, &required?/1)
|
||||
|
||||
~H"""
|
||||
<div id={@id}>
|
||||
|
|
@ -62,22 +61,15 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
{format_value_type(field_data.field)}
|
||||
</:col>
|
||||
|
||||
<:col :let={{_field_name, field_data}} label={gettext("Description")}>
|
||||
{field_data.description || ""}
|
||||
</:col>
|
||||
|
||||
<:col
|
||||
:let={{_field_name, field_data}}
|
||||
label={gettext("Required")}
|
||||
class="max-w-[9.375rem] text-center"
|
||||
>
|
||||
<span
|
||||
:if={@required?.(field_data.field)}
|
||||
class="text-base-content font-semibold"
|
||||
>
|
||||
<span :if={field_data.required} class="text-base-content font-semibold">
|
||||
{gettext("Required")}
|
||||
</span>
|
||||
<span :if={!@required?.(field_data.field)} class="text-base-content/70">
|
||||
<span :if={!field_data.required} class="text-base-content/70">
|
||||
{gettext("Optional")}
|
||||
</span>
|
||||
</:col>
|
||||
|
|
@ -173,26 +165,35 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
{:error, _} ->
|
||||
# Return a minimal struct-like map for fallback
|
||||
# This is only used for initial rendering, actual settings will be loaded properly
|
||||
%{member_field_visibility: %{}}
|
||||
%{member_field_visibility: %{}, member_field_required: %{}}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_member_fields_with_visibility(settings) do
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
visibility_config = settings.member_field_visibility || %{}
|
||||
required_config = settings.member_field_required || %{}
|
||||
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
|
||||
|
||||
# Normalize visibility config keys to atoms
|
||||
normalized_config = VisibilityConfig.normalize(visibility_config)
|
||||
normalized_visibility = VisibilityConfig.normalize(visibility_config)
|
||||
normalized_required = VisibilityConfig.normalize(required_config)
|
||||
|
||||
Enum.map(member_fields, fn field ->
|
||||
show_in_overview = Map.get(normalized_config, field, true)
|
||||
show_in_overview = Map.get(normalized_visibility, field, true)
|
||||
|
||||
# Email always required; Vereinfacht-required fields when integration active; else from settings
|
||||
required =
|
||||
field == :email ||
|
||||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
|
||||
Map.get(normalized_required, field, false)
|
||||
|
||||
attribute = Info.attribute(Mv.Membership.Member, field)
|
||||
|
||||
%{
|
||||
field: field,
|
||||
show_in_overview: show_in_overview,
|
||||
value_type: (attribute && attribute.type) || :string,
|
||||
description: nil
|
||||
required: required,
|
||||
value_type: (attribute && attribute.type) || :string
|
||||
}
|
||||
end)
|
||||
|> Enum.map(fn field_data ->
|
||||
|
|
@ -206,14 +207,4 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
|||
attribute -> FieldTypeFormatter.format(attribute.type)
|
||||
end
|
||||
end
|
||||
|
||||
# Check if a field is required by checking the actual attribute definition
|
||||
defp required?(field) when is_atom(field) do
|
||||
case Info.attribute(Mv.Membership.Member, field) do
|
||||
nil -> false
|
||||
attribute -> not attribute.allow_nil?
|
||||
end
|
||||
end
|
||||
|
||||
defp required?(_), do: false
|
||||
end
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.Helpers.VisibilityConfig
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
|
@ -84,47 +86,81 @@ defmodule MvWeb.MemberLive.Form do
|
|||
<%!-- Name Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-48">
|
||||
<.input field={@form[:first_name]} label={gettext("First Name")} />
|
||||
<.input
|
||||
field={@form[:first_name]}
|
||||
label={gettext("First Name")}
|
||||
required={@member_field_required_map[:first_name]}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<.input field={@form[:last_name]} label={gettext("Last Name")} />
|
||||
<.input
|
||||
field={@form[:last_name]}
|
||||
label={gettext("Last Name")}
|
||||
required={@member_field_required_map[:last_name]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Address Row --%>
|
||||
<%!-- Address: Country, Postal Code, City in one row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-1">
|
||||
<.input field={@form[:street]} label={gettext("Street")} />
|
||||
</div>
|
||||
<div class="w-16">
|
||||
<.input field={@form[:house_number]} label={gettext("Nr.")} />
|
||||
<div class="w-48">
|
||||
<.input field={@form[:country]} label={gettext("Country")} />
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<.input field={@form[:postal_code]} label={gettext("Postal Code")} />
|
||||
<.input
|
||||
field={@form[:postal_code]}
|
||||
label={gettext("Postal Code")}
|
||||
required={@member_field_required_map[:postal_code]}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-32">
|
||||
<div class="w-48">
|
||||
<.input field={@form[:city]} label={gettext("City")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Street and Nr. below --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-64">
|
||||
<.input field={@form[:street]} label={gettext("Street")} />
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<.input field={@form[:house_number]} label={gettext("Nr.")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Email --%>
|
||||
<div>
|
||||
<div class="w-64">
|
||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||
</div>
|
||||
|
||||
<%!-- Membership Dates Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-36">
|
||||
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />
|
||||
<.input
|
||||
field={@form[:join_date]}
|
||||
label={gettext("Join Date")}
|
||||
type="date"
|
||||
required={@member_field_required_map[:join_date]}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-36">
|
||||
<.input field={@form[:exit_date]} label={gettext("Exit Date")} type="date" />
|
||||
<.input
|
||||
field={@form[:exit_date]}
|
||||
label={gettext("Exit Date")}
|
||||
type="date"
|
||||
required={@member_field_required_map[:exit_date]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Notes --%>
|
||||
<div>
|
||||
<.input field={@form[:notes]} label={gettext("Notes")} type="textarea" />
|
||||
<.input
|
||||
field={@form[:notes]}
|
||||
label={gettext("Notes")}
|
||||
type="textarea"
|
||||
required={@member_field_required_map[:notes]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</.form_section>
|
||||
|
|
@ -254,6 +290,9 @@ defmodule MvWeb.MemberLive.Form do
|
|||
# Load available membership fee types
|
||||
available_fee_types = load_available_fee_types(member, actor)
|
||||
|
||||
# Load settings to know which member fields are required (for asterisk/tooltip)
|
||||
member_field_required_map = get_member_field_required_map()
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:return_to, return_to(params["return_to"]))
|
||||
|
|
@ -263,9 +302,38 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|> assign(:page_title, page_title)
|
||||
|> assign(:available_fee_types, available_fee_types)
|
||||
|> assign(:interval_warning, nil)
|
||||
|> assign(:member_field_required_map, member_field_required_map)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
defp get_member_field_required_map do
|
||||
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
|
||||
|
||||
case Membership.get_settings() do
|
||||
{:ok, settings} ->
|
||||
required_config = settings.member_field_required || %{}
|
||||
normalized = VisibilityConfig.normalize(required_config)
|
||||
|
||||
Mv.Constants.member_fields()
|
||||
|> Enum.map(fn field ->
|
||||
required =
|
||||
field == :email ||
|
||||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
|
||||
Map.get(normalized, field, false)
|
||||
|
||||
{field, required}
|
||||
end)
|
||||
|> Map.new()
|
||||
|
||||
{:error, _} ->
|
||||
# Email always required; Vereinfacht fields when integration active
|
||||
Map.new(Mv.Constants.member_fields(), fn f ->
|
||||
{f,
|
||||
f == :email || (vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(f))}
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
defp return_to("show"), do: "show"
|
||||
defp return_to(_), do: "index"
|
||||
|
||||
|
|
@ -319,11 +387,40 @@ defmodule MvWeb.MemberLive.Form do
|
|||
socket =
|
||||
socket
|
||||
|> put_flash(:info, flash_message)
|
||||
|> maybe_put_vereinfacht_sync_flash(member.id)
|
||||
|> push_navigate(to: return_path(socket.assigns.return_to, member))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp maybe_put_vereinfacht_sync_flash(socket, member_id) do
|
||||
case Mv.Vereinfacht.SyncFlash.take(to_string(member_id)) do
|
||||
{:warning, message} ->
|
||||
put_flash(socket, :warning, translate_vereinfacht_flash(message))
|
||||
|
||||
{:ok, _message} ->
|
||||
# Optionally show sync success; for now we keep only the main success message
|
||||
socket
|
||||
|
||||
nil ->
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp translate_vereinfacht_flash(message) when is_binary(message) do
|
||||
prefix = "Vereinfacht: "
|
||||
|
||||
if String.starts_with?(message, prefix) do
|
||||
detail = message |> String.trim_leading(prefix) |> String.trim()
|
||||
|
||||
Gettext.dgettext(MvWeb.Gettext, "default", "Vereinfacht: %{detail}",
|
||||
detail: Gettext.dgettext(MvWeb.Gettext, "default", detail)
|
||||
)
|
||||
else
|
||||
Gettext.dgettext(MvWeb.Gettext, "default", message)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_save_error(socket, form) do
|
||||
# Always show a flash message when save fails
|
||||
# Field-level validation errors are displayed in form fields, but flash provides additional feedback
|
||||
|
|
@ -606,6 +703,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|> extract_form_value(form, :house_number, &to_string/1)
|
||||
|> extract_form_value(form, :postal_code, &to_string/1)
|
||||
|> extract_form_value(form, :city, &to_string/1)
|
||||
|> extract_form_value(form, :country, &to_string/1)
|
||||
|> extract_form_value(form, :join_date, &format_date_value/1)
|
||||
|> extract_form_value(form, :exit_date, &format_date_value/1)
|
||||
|> extract_form_value(form, :notes, &to_string/1)
|
||||
|
|
|
|||
|
|
@ -615,7 +615,9 @@ defmodule MvWeb.MemberLive.Index do
|
|||
# -----------------------------------------------------------------
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
def handle_params(params, url, socket) do
|
||||
url = url || request_url_from_socket(socket)
|
||||
params = merge_fields_param_from_uri(params, url)
|
||||
prev_sig = build_signature(socket)
|
||||
|
||||
fields_in_url? =
|
||||
|
|
@ -625,20 +627,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
|
||||
url_selection = FieldSelection.parse_from_url(params)
|
||||
|
||||
merged_selection =
|
||||
FieldSelection.merge_sources(
|
||||
url_selection,
|
||||
socket.assigns.user_field_selection,
|
||||
%{}
|
||||
)
|
||||
|
||||
final_selection =
|
||||
FieldVisibility.merge_with_global_settings(
|
||||
merged_selection,
|
||||
socket.assigns.settings,
|
||||
socket.assigns.all_custom_fields
|
||||
)
|
||||
final_selection = compute_final_field_selection(fields_in_url?, url_selection, socket)
|
||||
|
||||
visible_member_fields =
|
||||
final_selection
|
||||
|
|
@ -682,6 +671,19 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> update_selection_assigns()
|
||||
end
|
||||
|
||||
# Update sort components after rendering
|
||||
socket =
|
||||
if socket.assigns[:sort_needs_update] do
|
||||
old_field = socket.assigns[:previous_sort_field] || socket.assigns.sort_field
|
||||
|
||||
socket
|
||||
|> update_sort_components(old_field, socket.assigns.sort_field, socket.assigns.sort_order)
|
||||
|> assign(:sort_needs_update, false)
|
||||
|> assign(:previous_sort_field, nil)
|
||||
else
|
||||
socket
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
|
|
@ -815,6 +817,70 @@ defmodule MvWeb.MemberLive.Index do
|
|||
add_boolean_filters(base_params, boolean_filters)
|
||||
end
|
||||
|
||||
defp compute_final_field_selection(true, url_selection, socket) do
|
||||
only_url =
|
||||
FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields)
|
||||
|
||||
visible_members = FieldVisibility.get_visible_member_fields(only_url)
|
||||
visible_custom = FieldVisibility.get_visible_custom_fields(only_url)
|
||||
|
||||
if visible_members == [] and visible_custom == [] do
|
||||
# URL had only invalid field names; fall back to session + global.
|
||||
compute_final_field_selection(false, url_selection, socket)
|
||||
else
|
||||
only_url
|
||||
end
|
||||
end
|
||||
|
||||
defp compute_final_field_selection(false, url_selection, socket) do
|
||||
merged =
|
||||
FieldSelection.merge_sources(
|
||||
url_selection,
|
||||
socket.assigns.user_field_selection,
|
||||
%{}
|
||||
)
|
||||
|
||||
FieldVisibility.merge_with_global_settings(
|
||||
merged,
|
||||
socket.assigns.settings,
|
||||
socket.assigns.all_custom_fields
|
||||
)
|
||||
end
|
||||
|
||||
# On full page load conn.params has no query string; read "fields" from URI so column visibility is restored.
|
||||
defp request_url_from_socket(socket) do
|
||||
case socket.private[:connect_info] do
|
||||
%Plug.Conn{} = conn -> Plug.Conn.request_url(conn)
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp merge_fields_param_from_uri(params, nil), do: params
|
||||
|
||||
defp merge_fields_param_from_uri(params, %URI{query: query}) when is_binary(query) do
|
||||
case URI.decode_query(query)["fields"] do
|
||||
nil -> params
|
||||
value -> Map.put(params, "fields", value)
|
||||
end
|
||||
end
|
||||
|
||||
defp merge_fields_param_from_uri(params, %URI{}), do: params
|
||||
|
||||
defp merge_fields_param_from_uri(params, url) when is_binary(url) do
|
||||
case URI.parse(url).query do
|
||||
nil ->
|
||||
params
|
||||
|
||||
q ->
|
||||
case URI.decode_query(q)["fields"] do
|
||||
nil -> params
|
||||
value -> Map.put(params, "fields", value)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp merge_fields_param_from_uri(params, _), do: params
|
||||
|
||||
defp build_base_params(query, sort_field, sort_order) do
|
||||
%{
|
||||
"query" => query || "",
|
||||
|
|
@ -900,6 +966,15 @@ defmodule MvWeb.MemberLive.Index do
|
|||
query =
|
||||
Ash.Query.load(query, groups: [:id, :name, :slug])
|
||||
|
||||
# Load membership_fee_type when the column is visible or when sorting by it
|
||||
query =
|
||||
if :membership_fee_type in socket.assigns.member_fields_visible or
|
||||
socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do
|
||||
Ash.Query.load(query, membership_fee_type: [:id, :name])
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
query = apply_search_filter(query, search_query)
|
||||
|
||||
query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups])
|
||||
|
|
@ -940,9 +1015,10 @@ defmodule MvWeb.MemberLive.Index do
|
|||
)
|
||||
|
||||
# Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked)
|
||||
# Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status
|
||||
members =
|
||||
if sort_after_load and
|
||||
socket.assigns.sort_field not in FieldVisibility.computed_member_fields() do
|
||||
socket.assigns.sort_field != :membership_fee_status do
|
||||
sort_members_in_memory(
|
||||
members,
|
||||
socket.assigns.sort_field,
|
||||
|
|
@ -1044,27 +1120,25 @@ defmodule MvWeb.MemberLive.Index do
|
|||
defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false}
|
||||
|
||||
defp maybe_sort(query, field, order, _custom_fields) do
|
||||
if computed_field?(field) do
|
||||
# :groups is in computed_member_fields() but can be sorted in-memory
|
||||
# Only :membership_fee_status should be blocked from sorting
|
||||
if field == :membership_fee_status or field == "membership_fee_status" do
|
||||
{query, false}
|
||||
else
|
||||
apply_sort_to_query(query, field, order)
|
||||
end
|
||||
end
|
||||
|
||||
defp computed_field?(field) do
|
||||
computed_atoms = FieldVisibility.computed_member_fields()
|
||||
computed_strings = Enum.map(computed_atoms, &Atom.to_string/1)
|
||||
|
||||
(is_atom(field) and field in computed_atoms) or
|
||||
(is_binary(field) and field in computed_strings)
|
||||
end
|
||||
|
||||
defp apply_sort_to_query(query, field, order) do
|
||||
cond do
|
||||
# Groups sort -> after load (in memory)
|
||||
field in [:groups, "groups"] ->
|
||||
{query, true}
|
||||
|
||||
# Membership fee type sort -> by related name at DB
|
||||
field in [:membership_fee_type, "membership_fee_type"] ->
|
||||
{Ash.Query.sort(query, [{"membership_fee_type.name", order}]), false}
|
||||
|
||||
# Custom field sort -> after load
|
||||
custom_field_sort?(field) ->
|
||||
{query, true}
|
||||
|
|
@ -1086,13 +1160,19 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
|
||||
defp valid_sort_field?(field) when is_atom(field) do
|
||||
if field in FieldVisibility.computed_member_fields(),
|
||||
do: false,
|
||||
else: valid_sort_field_db_or_custom?(field)
|
||||
# :groups is in computed_member_fields() but can be sorted
|
||||
# Only :membership_fee_status should be blocked
|
||||
if field == :membership_fee_status do
|
||||
false
|
||||
else
|
||||
valid_sort_field_db_or_custom?(field)
|
||||
end
|
||||
end
|
||||
|
||||
defp valid_sort_field?(field) when is_binary(field) do
|
||||
if field in Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) do
|
||||
# "groups" is in computed_member_fields() but can be sorted
|
||||
# Only "membership_fee_status" should be blocked
|
||||
if field == "membership_fee_status" do
|
||||
false
|
||||
else
|
||||
valid_sort_field_db_or_custom?(field)
|
||||
|
|
@ -1104,11 +1184,16 @@ defmodule MvWeb.MemberLive.Index do
|
|||
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
|
||||
non_sortable_fields = [:notes]
|
||||
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
|
||||
field in valid_fields or custom_field_sort?(field) or field == :groups
|
||||
field in valid_fields or custom_field_sort?(field) or field in [:groups, :membership_fee_type]
|
||||
end
|
||||
|
||||
defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
|
||||
normalized = if field == "groups", do: :groups, else: safe_member_field_atom_only(field)
|
||||
normalized =
|
||||
cond do
|
||||
field == "groups" -> :groups
|
||||
field == "membership_fee_type" -> :membership_fee_type
|
||||
true -> safe_member_field_atom_only(field)
|
||||
end
|
||||
|
||||
(normalized != nil and valid_sort_field_db_or_custom?(normalized)) or
|
||||
custom_field_sort?(field)
|
||||
|
|
@ -1249,10 +1334,13 @@ defmodule MvWeb.MemberLive.Index do
|
|||
defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do
|
||||
field = determine_field(socket.assigns.sort_field, sf)
|
||||
order = determine_order(socket.assigns.sort_order, so)
|
||||
old_field = socket.assigns.sort_field
|
||||
|
||||
socket
|
||||
|> assign(:sort_field, field)
|
||||
|> assign(:sort_order, order)
|
||||
|> assign(:sort_needs_update, old_field != field or socket.assigns.sort_order != order)
|
||||
|> assign(:previous_sort_field, old_field)
|
||||
end
|
||||
|
||||
defp maybe_update_sort(socket, _), do: socket
|
||||
|
|
@ -1261,17 +1349,27 @@ defmodule MvWeb.MemberLive.Index do
|
|||
defp determine_field(default, nil), do: default
|
||||
|
||||
defp determine_field(default, sf) when is_binary(sf) do
|
||||
computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1)
|
||||
# Handle "groups" specially - it's in computed_member_fields() but can be sorted
|
||||
if sf == "groups" do
|
||||
:groups
|
||||
else
|
||||
computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1)
|
||||
|
||||
if sf in computed_strings,
|
||||
do: default,
|
||||
else: determine_field_after_computed_check(default, sf)
|
||||
if sf in computed_strings,
|
||||
do: default,
|
||||
else: determine_field_after_computed_check(default, sf)
|
||||
end
|
||||
end
|
||||
|
||||
defp determine_field(default, sf) when is_atom(sf) do
|
||||
if sf in FieldVisibility.computed_member_fields(),
|
||||
do: default,
|
||||
else: determine_field_after_computed_check(default, sf)
|
||||
# Handle :groups specially - it's in computed_member_fields() but can be sorted
|
||||
if sf == :groups do
|
||||
:groups
|
||||
else
|
||||
if sf in FieldVisibility.computed_member_fields(),
|
||||
do: default,
|
||||
else: determine_field_after_computed_check(default, sf)
|
||||
end
|
||||
end
|
||||
|
||||
defp determine_field(default, _), do: default
|
||||
|
|
@ -1620,6 +1718,12 @@ defmodule MvWeb.MemberLive.Index do
|
|||
FieldVisibility.computed_member_fields()
|
||||
|> Enum.filter(&(&1 in member_fields_computed))
|
||||
|
||||
member_fields_with_groups =
|
||||
build_export_member_fields_list(
|
||||
ordered_member_fields_db,
|
||||
socket.assigns[:member_fields_visible]
|
||||
)
|
||||
|
||||
# Order custom fields like the table (same as dynamic_cols / all_custom_fields order)
|
||||
ordered_custom_field_ids =
|
||||
socket.assigns.all_custom_fields
|
||||
|
|
@ -1628,14 +1732,20 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
%{
|
||||
selected_ids: socket.assigns.selected_members |> MapSet.to_list(),
|
||||
member_fields: Enum.map(ordered_member_fields_db, &Atom.to_string/1),
|
||||
member_fields:
|
||||
Enum.map(member_fields_with_groups, fn
|
||||
f when is_atom(f) -> Atom.to_string(f)
|
||||
f when is_binary(f) -> f
|
||||
end),
|
||||
computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1),
|
||||
custom_field_ids: ordered_custom_field_ids,
|
||||
column_order:
|
||||
export_column_order(
|
||||
ordered_member_fields_db,
|
||||
ordered_computed_fields,
|
||||
ordered_custom_field_ids
|
||||
ordered_custom_field_ids,
|
||||
:membership_fee_type in socket.assigns[:member_fields_visible],
|
||||
:groups in socket.assigns[:member_fields_visible]
|
||||
),
|
||||
query: socket.assigns[:query] || nil,
|
||||
sort_field: export_sort_field(socket.assigns[:sort_field]),
|
||||
|
|
@ -1646,6 +1756,41 @@ defmodule MvWeb.MemberLive.Index do
|
|||
}
|
||||
end
|
||||
|
||||
defp expand_db_string_for_export(f, membership_fee_type_visible, computed_strings) do
|
||||
if f == "membership_fee_start_date" do
|
||||
extra =
|
||||
if(membership_fee_type_visible, do: ["membership_fee_type"], else: []) ++
|
||||
if "membership_fee_status" in computed_strings, do: ["membership_fee_status"], else: []
|
||||
|
||||
[f] ++ extra
|
||||
else
|
||||
[f]
|
||||
end
|
||||
end
|
||||
|
||||
defp build_export_member_fields_list(ordered_db, member_fields_visible) do
|
||||
with_extras =
|
||||
Enum.flat_map(ordered_db, fn f ->
|
||||
if f == :membership_fee_start_date and
|
||||
:membership_fee_type in (member_fields_visible || []) do
|
||||
[f, :membership_fee_type]
|
||||
else
|
||||
[f]
|
||||
end
|
||||
end)
|
||||
|
||||
# If fee type is visible but start_date was not in the list, append it
|
||||
with_extras =
|
||||
if :membership_fee_type in (member_fields_visible || []) and
|
||||
:membership_fee_type not in with_extras do
|
||||
with_extras ++ [:membership_fee_type]
|
||||
else
|
||||
with_extras
|
||||
end
|
||||
|
||||
if :groups in (member_fields_visible || []), do: with_extras ++ [:groups], else: with_extras
|
||||
end
|
||||
|
||||
defp export_cycle_status_filter(nil), do: nil
|
||||
defp export_cycle_status_filter(:paid), do: "paid"
|
||||
defp export_cycle_status_filter(:unpaid), do: "unpaid"
|
||||
|
|
@ -1661,31 +1806,41 @@ defmodule MvWeb.MemberLive.Index do
|
|||
defp export_sort_order(o) when is_binary(o), do: o
|
||||
# Build a single ordered list that matches the table order:
|
||||
# - DB fields in Mv.Constants.member_fields() order (already pre-filtered as ordered_member_fields_db)
|
||||
# - computed fields inserted at the correct position (membership_fee_status after membership_fee_start_date)
|
||||
# - membership_fee_type and membership_fee_status inserted after membership_fee_start_date when visible
|
||||
# - groups appended before custom fields when visible
|
||||
# - custom fields appended in the same order as table (already ordered_custom_field_ids)
|
||||
defp export_column_order(
|
||||
ordered_member_fields_db,
|
||||
ordered_computed_fields,
|
||||
ordered_custom_field_ids
|
||||
ordered_custom_field_ids,
|
||||
membership_fee_type_visible,
|
||||
groups_visible
|
||||
) do
|
||||
db_strings = Enum.map(ordered_member_fields_db, &Atom.to_string/1)
|
||||
computed_strings = Enum.map(ordered_computed_fields, &Atom.to_string/1)
|
||||
|
||||
# Place membership_fee_status right after membership_fee_start_date if present in export
|
||||
db_with_computed =
|
||||
Enum.flat_map(db_strings, fn f ->
|
||||
if f == "membership_fee_start_date" and "membership_fee_status" in computed_strings do
|
||||
[f, "membership_fee_status"]
|
||||
else
|
||||
[f]
|
||||
end
|
||||
end)
|
||||
# Place membership_fee_type and membership_fee_status after membership_fee_start_date when present
|
||||
db_with_extras =
|
||||
Enum.flat_map(
|
||||
db_strings,
|
||||
&expand_db_string_for_export(&1, membership_fee_type_visible, computed_strings)
|
||||
)
|
||||
|
||||
# If fee type is visible but start_date was not in the list, append it before computed/groups
|
||||
db_with_extras =
|
||||
if membership_fee_type_visible and "membership_fee_type" not in db_with_extras do
|
||||
db_with_extras ++ ["membership_fee_type"]
|
||||
else
|
||||
db_with_extras
|
||||
end
|
||||
|
||||
# Any remaining computed fields not inserted above (future-proof)
|
||||
remaining_computed =
|
||||
computed_strings
|
||||
|> Enum.reject(&(&1 in db_with_computed))
|
||||
|> Enum.reject(&(&1 in db_with_extras))
|
||||
|
||||
db_with_computed ++ remaining_computed ++ ordered_custom_field_ids
|
||||
result = db_with_extras ++ remaining_computed
|
||||
result = if groups_visible, do: result ++ ["groups"], else: result
|
||||
result ++ ordered_custom_field_ids
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -223,6 +223,24 @@
|
|||
>
|
||||
{member.notes}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:country in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_country}
|
||||
field={:country}
|
||||
label={gettext("Country")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.country}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:city in @member_fields_visible}
|
||||
|
|
@ -313,6 +331,28 @@
|
|||
>
|
||||
{MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:membership_fee_type in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_membership_fee_type}
|
||||
field={:membership_fee_type}
|
||||
label={gettext("Fee Type")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
<%= if member.membership_fee_type do %>
|
||||
{member.membership_fee_type.name}
|
||||
<% else %>
|
||||
<span class="text-base-content/50">—</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:membership_fee_status in @member_fields_visible}
|
||||
|
|
@ -331,6 +371,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:groups in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
alias Mv.Membership.Helpers.VisibilityConfig
|
||||
|
||||
# Single UI key for "Membership Fee Status"; only this appears in the dropdown.
|
||||
@pseudo_member_fields [:membership_fee_status]
|
||||
# Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table).
|
||||
@pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups]
|
||||
|
||||
# Export/API may accept this as alias; must not appear in the UI options list.
|
||||
@export_only_alias :payment_status
|
||||
|
|
@ -63,6 +64,25 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
member_fields ++ custom_field_names
|
||||
end
|
||||
|
||||
@doc """
|
||||
Builds field selection from URL only: fields in `url_selection` are visible, all others false.
|
||||
Use when `?fields=...` is in the URL so column visibility is not merged with global settings.
|
||||
"""
|
||||
@spec selection_from_url_only(%{String.t() => boolean()}, [struct()]) :: %{
|
||||
String.t() => boolean()
|
||||
}
|
||||
def selection_from_url_only(url_selection, custom_fields) when is_map(url_selection) do
|
||||
all_fields = get_all_available_fields(custom_fields)
|
||||
|
||||
Enum.reduce(all_fields, %{}, fn field, acc ->
|
||||
field_string = field_to_string(field)
|
||||
visible = Map.get(url_selection, field_string, false)
|
||||
Map.put(acc, field_string, visible)
|
||||
end)
|
||||
end
|
||||
|
||||
def selection_from_url_only(_, _), do: %{}
|
||||
|
||||
@doc """
|
||||
Merges user field selection with global settings.
|
||||
|
||||
|
|
@ -201,7 +221,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
"""
|
||||
@spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()]
|
||||
def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do
|
||||
computed_set = MapSet.new(@pseudo_member_fields)
|
||||
computed_set = MapSet.new([:membership_fee_status])
|
||||
|
||||
field_selection
|
||||
|> Enum.filter(fn {field_string, visible} ->
|
||||
|
|
|
|||
|
|
@ -256,6 +256,7 @@ defmodule MvWeb.MemberLive.Show do
|
|||
id={"membership-fees-#{@member.id}"}
|
||||
member={@member}
|
||||
current_user={@current_user}
|
||||
vereinfacht_receipts={@vereinfacht_receipts}
|
||||
/>
|
||||
<% end %>
|
||||
</Layouts.app>
|
||||
|
|
@ -264,7 +265,10 @@ defmodule MvWeb.MemberLive.Show do
|
|||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, :active_tab, :contact)}
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:active_tab, :contact)
|
||||
|> assign(:vereinfacht_receipts, nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -316,6 +320,16 @@ defmodule MvWeb.MemberLive.Show do
|
|||
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
||||
end
|
||||
|
||||
def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do
|
||||
response =
|
||||
case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do
|
||||
{:ok, receipts} -> {:ok, receipts}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :vereinfacht_receipts, response)}
|
||||
end
|
||||
|
||||
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
|
||||
@impl true
|
||||
def handle_info({:put_flash, type, message}, socket) do
|
||||
|
|
@ -437,8 +451,8 @@ defmodule MvWeb.MemberLive.Show do
|
|||
|> Enum.filter(&(&1 && &1 != ""))
|
||||
|> Enum.join(" ")
|
||||
|
||||
[street_part, city_part]
|
||||
|> Enum.filter(&(&1 != ""))
|
||||
[member.country, street_part, city_part]
|
||||
|> Enum.filter(&(&1 && &1 != ""))
|
||||
|> Enum.join(", ")
|
||||
|> case do
|
||||
"" -> nil
|
||||
|
|
|
|||
|
|
@ -50,6 +50,90 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Vereinfacht: contact info when synced, or warning when API is configured but no contact --%>
|
||||
<%= if Mv.Config.vereinfacht_configured?() do %>
|
||||
<%= if @vereinfacht_contact_present do %>
|
||||
<div class="mb-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<.link
|
||||
:if={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)}
|
||||
href={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="link link-accent underline inline-flex items-center gap-1 w-fit"
|
||||
>
|
||||
{gettext("View contact in Vereinfacht")}
|
||||
<.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" />
|
||||
</.link>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="load_vereinfacht_receipts"
|
||||
phx-value-contact_id={@member.vereinfacht_contact_id}
|
||||
class="btn btn-sm btn-ghost"
|
||||
>
|
||||
{gettext("Show bookings/receipts from Vereinfacht")}
|
||||
</button>
|
||||
</div>
|
||||
<%= if @vereinfacht_receipts do %>
|
||||
<div
|
||||
class="mt-2 rounded border border-base-300 bg-base-200 p-3 overflow-x-auto max-h-96 overflow-y-auto"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
aria-label={gettext("Vereinfacht receipts")}
|
||||
>
|
||||
<%= if match?({:ok, _}, @vereinfacht_receipts) do %>
|
||||
<% {_, receipts} = @vereinfacht_receipts %>
|
||||
<%= if receipts == [] do %>
|
||||
<p class="text-sm text-base-content/70">{gettext("No receipts")}</p>
|
||||
<% else %>
|
||||
<% cols = receipt_display_columns(receipts) %>
|
||||
<table class="table table-xs table-pin-rows">
|
||||
<thead>
|
||||
<tr>
|
||||
<%= for {_key, translated_label} <- cols do %>
|
||||
<th>{translated_label}</th>
|
||||
<% end %>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for r <- receipts do %>
|
||||
<tr>
|
||||
<%= for {col_key, _header_key} <- cols do %>
|
||||
<td>{format_receipt_cell(col_key, r[col_key])}</td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<% {:error, reason} = @vereinfacht_receipts %>
|
||||
<p class="text-sm text-error">
|
||||
{gettext("Error loading receipts: %{reason}",
|
||||
reason: format_vereinfacht_error(reason)
|
||||
)}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="mb-4 rounded-lg border border-warning/50 bg-warning/10 p-3">
|
||||
<p class="text-warning font-medium flex items-center gap-2">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
|
||||
{gettext("No Vereinfacht contact exists for this member.")}
|
||||
</p>
|
||||
<p class="text-sm text-base-content/70 mt-1">
|
||||
{gettext(
|
||||
"Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%!-- Action Buttons (only when user has permission) --%>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<.button
|
||||
|
|
@ -130,47 +214,49 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
</:col>
|
||||
|
||||
<:action :let={cycle}>
|
||||
<div class="flex gap-1">
|
||||
<div class="flex gap-2">
|
||||
<%= if @can_update_cycle do %>
|
||||
<button
|
||||
:if={cycle.status != :paid}
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="paid"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-success"
|
||||
title={gettext("Mark as paid")}
|
||||
>
|
||||
<.icon name="hero-check-circle" class="size-4" />
|
||||
{gettext("Paid")}
|
||||
</button>
|
||||
<button
|
||||
:if={cycle.status != :suspended}
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="suspended"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-outline btn-warning"
|
||||
title={gettext("Mark as suspended")}
|
||||
>
|
||||
<.icon name="hero-pause-circle" class="size-4" />
|
||||
{gettext("Suspended")}
|
||||
</button>
|
||||
<button
|
||||
:if={cycle.status != :unpaid}
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="unpaid"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-error"
|
||||
title={gettext("Mark as unpaid")}
|
||||
>
|
||||
<.icon name="hero-x-circle" class="size-4" />
|
||||
{gettext("Unpaid")}
|
||||
</button>
|
||||
<div class="join">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="paid"
|
||||
phx-target={@myself}
|
||||
class={cycle_status_btn_class(cycle.status, :paid)}
|
||||
aria-pressed={cycle.status == :paid}
|
||||
title={gettext("Mark as paid")}
|
||||
>
|
||||
<.icon name="hero-check-circle" class="size-4" />
|
||||
{gettext("Paid")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="suspended"
|
||||
phx-target={@myself}
|
||||
class={cycle_status_btn_class(cycle.status, :suspended)}
|
||||
aria-pressed={cycle.status == :suspended}
|
||||
title={gettext("Mark as suspended")}
|
||||
>
|
||||
<.icon name="hero-pause-circle" class="size-4" />
|
||||
{gettext("Suspended")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="mark_cycle_status"
|
||||
phx-value-cycle_id={cycle.id}
|
||||
phx-value-status="unpaid"
|
||||
phx-target={@myself}
|
||||
class={cycle_status_btn_class(cycle.status, :unpaid)}
|
||||
aria-pressed={cycle.status == :unpaid}
|
||||
title={gettext("Mark as unpaid")}
|
||||
>
|
||||
<.icon name="hero-x-circle" class="size-4" />
|
||||
{gettext("Unpaid")}
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @can_destroy_cycle do %>
|
||||
<button
|
||||
|
|
@ -431,6 +517,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|> assign(:can_create_cycle, can_create_cycle)
|
||||
|> assign(:can_destroy_cycle, can_destroy_cycle)
|
||||
|> assign(:can_update_cycle, can_update_cycle)
|
||||
|> assign(:vereinfacht_contact_present, present_contact_id?(member.vereinfacht_contact_id))
|
||||
|> assign_new(:interval_warning, fn -> nil end)
|
||||
|> assign_new(:editing_cycle, fn -> nil end)
|
||||
|> assign_new(:deleting_cycle, fn -> nil end)
|
||||
|
|
@ -439,7 +526,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|> assign_new(:creating_cycle, fn -> false end)
|
||||
|> assign_new(:create_cycle_date, fn -> nil end)
|
||||
|> assign_new(:create_cycle_error, fn -> nil end)
|
||||
|> assign_new(:regenerating, fn -> false end)}
|
||||
|> assign_new(:regenerating, fn -> false end)
|
||||
|> assign_new(:vereinfacht_receipts, fn -> nil end)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -997,6 +1085,156 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
|
||||
defp format_create_cycle_period(_date, _interval), do: ""
|
||||
|
||||
defp present_contact_id?(nil), do: false
|
||||
defp present_contact_id?(id) when is_binary(id), do: String.trim(id) != ""
|
||||
defp present_contact_id?(_), do: false
|
||||
|
||||
defp format_vereinfacht_error({:http, status, detail}) when is_binary(detail),
|
||||
do: "HTTP #{status} – #{detail}"
|
||||
|
||||
defp format_vereinfacht_error({:http, status, _}), do: "HTTP #{status}"
|
||||
defp format_vereinfacht_error(reason), do: inspect(reason)
|
||||
|
||||
# Ordered receipt columns: {api_key, gettext key for header}. Only columns present in data are shown.
|
||||
@receipt_column_spec [
|
||||
{:amount, "Amount"},
|
||||
{:bookingDate, "Booking date"},
|
||||
{:createdAt, "Created at"},
|
||||
{:receiptType, "Receipt type"},
|
||||
{:referenceNumber, "Reference number"},
|
||||
{:status, "Status"},
|
||||
{:updatedAt, "Updated at"}
|
||||
]
|
||||
|
||||
defp receipt_display_columns(receipts) when is_list(receipts) do
|
||||
keys_in_data = receipts |> Enum.flat_map(&Map.keys/1) |> MapSet.new()
|
||||
|
||||
Enum.filter(@receipt_column_spec, fn {key, _} -> MapSet.member?(keys_in_data, key) end)
|
||||
|> Enum.map(fn {key, msgid} -> {key, Gettext.gettext(MvWeb.Gettext, msgid)} end)
|
||||
end
|
||||
|
||||
defp format_receipt_cell(:amount, nil), do: "—"
|
||||
|
||||
defp format_receipt_cell(:amount, val) when is_number(val) do
|
||||
case Decimal.cast(val) do
|
||||
{:ok, d} -> MembershipFeeHelpers.format_currency(d)
|
||||
_ -> to_string(val)
|
||||
end
|
||||
end
|
||||
|
||||
defp format_receipt_cell(:amount, val) when is_binary(val) do
|
||||
case Decimal.parse(val) do
|
||||
{d, _} -> MembershipFeeHelpers.format_currency(d)
|
||||
:error -> val
|
||||
end
|
||||
end
|
||||
|
||||
defp format_receipt_cell(:amount, val), do: to_string(val)
|
||||
|
||||
defp format_receipt_cell(:status, nil), do: "—"
|
||||
|
||||
defp format_receipt_cell(:status, val) when is_binary(val) do
|
||||
translate_receipt_status(val)
|
||||
end
|
||||
|
||||
defp format_receipt_cell(:status, val), do: translate_receipt_status(to_string(val))
|
||||
|
||||
defp format_receipt_cell(:receiptType, nil), do: "—"
|
||||
|
||||
defp format_receipt_cell(:receiptType, val) when is_binary(val) do
|
||||
translate_receipt_type(val)
|
||||
end
|
||||
|
||||
defp format_receipt_cell(:receiptType, val), do: translate_receipt_type(to_string(val))
|
||||
|
||||
defp format_receipt_cell(col_key, nil) when col_key in [:bookingDate, :createdAt, :updatedAt],
|
||||
do: "—"
|
||||
|
||||
defp format_receipt_cell(col_key, val) when col_key in [:bookingDate, :createdAt, :updatedAt] do
|
||||
format_receipt_date(val)
|
||||
end
|
||||
|
||||
defp format_receipt_cell(_col_key, val) when is_binary(val), do: val
|
||||
defp format_receipt_cell(_col_key, val) when is_number(val), do: to_string(val)
|
||||
|
||||
defp format_receipt_cell(_col_key, val) when is_boolean(val),
|
||||
do: if(val, do: gettext("Yes"), else: gettext("No"))
|
||||
|
||||
defp format_receipt_cell(_col_key, %Date{} = d), do: format_receipt_date_short(d)
|
||||
defp format_receipt_cell(_col_key, val) when is_map(val) or is_list(val), do: Jason.encode!(val)
|
||||
defp format_receipt_cell(_col_key, val), do: to_string(val)
|
||||
|
||||
defp format_receipt_date(%Date{} = d), do: format_receipt_date_short(d)
|
||||
|
||||
defp format_receipt_date(val) when is_binary(val) do
|
||||
case parse_receipt_date(val) do
|
||||
{:ok, d} -> format_receipt_date_short(d)
|
||||
_ -> val
|
||||
end
|
||||
end
|
||||
|
||||
defp format_receipt_date(val), do: to_string(val)
|
||||
|
||||
# Parses ISO date or datetime string to Date (uses first 10 chars for datetime strings)
|
||||
defp parse_receipt_date(val) when is_binary(val) do
|
||||
date_str = if String.length(val) >= 10, do: String.slice(val, 0, 10), else: val
|
||||
Date.from_iso8601(date_str)
|
||||
end
|
||||
|
||||
# Format as "12. Dez. 2025" (day. abbreviated month. year) with translated month
|
||||
defp format_receipt_date_short(%Date{day: day, month: month, year: year}) do
|
||||
"#{day}. #{receipt_month_abbr(month)} #{year}"
|
||||
end
|
||||
|
||||
defp receipt_month_abbr(1), do: gettext("Jan.")
|
||||
defp receipt_month_abbr(2), do: gettext("Feb.")
|
||||
defp receipt_month_abbr(3), do: gettext("Mar.")
|
||||
defp receipt_month_abbr(4), do: gettext("Apr.")
|
||||
defp receipt_month_abbr(5), do: gettext("May")
|
||||
defp receipt_month_abbr(6), do: gettext("Jun.")
|
||||
defp receipt_month_abbr(7), do: gettext("Jul.")
|
||||
defp receipt_month_abbr(8), do: gettext("Aug.")
|
||||
defp receipt_month_abbr(9), do: gettext("Sep.")
|
||||
defp receipt_month_abbr(10), do: gettext("Oct.")
|
||||
defp receipt_month_abbr(11), do: gettext("Nov.")
|
||||
defp receipt_month_abbr(12), do: gettext("Dec.")
|
||||
defp receipt_month_abbr(_), do: ""
|
||||
|
||||
# Translate API status values for display (extend as API returns more values)
|
||||
defp translate_receipt_status("paid"), do: gettext("Paid")
|
||||
defp translate_receipt_status("unpaid"), do: gettext("Unpaid")
|
||||
defp translate_receipt_status("suspended"), do: gettext("Suspended")
|
||||
defp translate_receipt_status("open"), do: gettext("Open")
|
||||
defp translate_receipt_status("cancelled"), do: gettext("Cancelled")
|
||||
defp translate_receipt_status("draft"), do: gettext("Draft")
|
||||
defp translate_receipt_status("incompleted"), do: gettext("Incompleted")
|
||||
defp translate_receipt_status("completed"), do: gettext("Completed")
|
||||
defp translate_receipt_status("empty"), do: "—"
|
||||
defp translate_receipt_status(other), do: other
|
||||
|
||||
# Translate API receipt type values (extend as API returns more values)
|
||||
defp translate_receipt_type("invoice"), do: gettext("Invoice")
|
||||
defp translate_receipt_type("receipt"), do: gettext("Receipt")
|
||||
defp translate_receipt_type("credit_note"), do: gettext("Credit note")
|
||||
defp translate_receipt_type("credit"), do: gettext("Credit")
|
||||
defp translate_receipt_type("expense"), do: gettext("Expense")
|
||||
defp translate_receipt_type("income"), do: gettext("Income")
|
||||
defp translate_receipt_type(other), do: other
|
||||
|
||||
# Returns CSS classes for a cycle status button.
|
||||
# Active (current) status is highlighted with color and non-interactive;
|
||||
# inactive buttons are neutral gray. Matches the filter button pattern.
|
||||
defp cycle_status_btn_class(current_status, btn_status) do
|
||||
base = "join-item btn btn-sm"
|
||||
|
||||
case {current_status == btn_status, btn_status} do
|
||||
{true, :paid} -> "#{base} btn-success btn-active pointer-events-none"
|
||||
{true, :suspended} -> "#{base} btn-warning btn-active pointer-events-none"
|
||||
{true, :unpaid} -> "#{base} btn-error btn-active pointer-events-none"
|
||||
_ -> base
|
||||
end
|
||||
end
|
||||
|
||||
# Helper component for section box
|
||||
attr :title, :string, required: true
|
||||
slot :inner_block, required: true
|
||||
|
|
|
|||
|
|
@ -1,17 +1,23 @@
|
|||
defmodule MvWeb.MembershipFeeSettingsLive do
|
||||
@moduledoc """
|
||||
LiveView for managing membership fee settings (Admin).
|
||||
LiveView for membership fee settings and fee types (Admin).
|
||||
|
||||
Allows administrators to configure:
|
||||
- Default membership fee type for new members
|
||||
- Whether to include the joining cycle in membership fee generation
|
||||
Combines:
|
||||
- Global settings (default fee type, include joining cycle)
|
||||
- Membership fee types table (CRUD links to new/edit routes; delete inline)
|
||||
Examples and info are collapsible to save space.
|
||||
"""
|
||||
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
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
|
|
@ -23,11 +29,14 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
|> Ash.Query.sort(name: :asc)
|
||||
|> Ash.read!(domain: Mv.MembershipFees, actor: actor)
|
||||
|
||||
member_counts = load_member_counts(membership_fee_types, actor)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Membership Fee Settings"))
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:membership_fee_types, membership_fee_types)
|
||||
|> assign(:member_counts, member_counts)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
|
|
@ -81,6 +90,51 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
end
|
||||
end
|
||||
|
||||
@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(:info, 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
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
|
|
@ -88,8 +142,13 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
<.header>
|
||||
{gettext("Membership Fee Settings")}
|
||||
<:subtitle>
|
||||
{gettext("Configure global settings for membership fees.")}
|
||||
{gettext("Configure global settings and fee types for membership fees.")}
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<.button variant="primary" navigate={~p"/membership_fee_settings/new_fee_type"}>
|
||||
<.icon name="hero-plus" /> {gettext("New Membership Fee Type")}
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
|
|
@ -188,58 +247,169 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Examples Card --%>
|
||||
<%!-- Examples Card (collapsible) --%>
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<.icon name="hero-light-bulb" class="size-5" />
|
||||
{gettext("Examples")}
|
||||
</h2>
|
||||
<details class="group">
|
||||
<summary class="card-title cursor-pointer list-none flex items-center gap-2">
|
||||
<.icon name="hero-chevron-right" class="size-5 transition group-open:rotate-90" />
|
||||
<.icon name="hero-light-bulb" class="size-5" />
|
||||
{gettext("Examples")}
|
||||
</summary>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Yearly Interval - Joining Cycle Included")}
|
||||
joining_date="15.03.2023"
|
||||
include_joining={true}
|
||||
start_date="01.01.2023"
|
||||
periods={["2023", "2024", "2025"]}
|
||||
note={gettext("Member pays for the year they joined")}
|
||||
/>
|
||||
<div class="pt-4 space-y-4">
|
||||
<.example_section
|
||||
title={gettext("Yearly Interval - Joining Cycle Included")}
|
||||
joining_date="15.03.2023"
|
||||
include_joining={true}
|
||||
start_date="01.01.2023"
|
||||
periods={["2023", "2024", "2025"]}
|
||||
note={gettext("Member pays for the year they joined")}
|
||||
/>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="divider"></div>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Yearly Interval - Joining Cycle Excluded")}
|
||||
joining_date="15.03.2023"
|
||||
include_joining={false}
|
||||
start_date="01.01.2024"
|
||||
periods={["2024", "2025"]}
|
||||
note={gettext("Member pays from the next full year")}
|
||||
/>
|
||||
<.example_section
|
||||
title={gettext("Yearly Interval - Joining Cycle Excluded")}
|
||||
joining_date="15.03.2023"
|
||||
include_joining={false}
|
||||
start_date="01.01.2024"
|
||||
periods={["2024", "2025"]}
|
||||
note={gettext("Member pays from the next full year")}
|
||||
/>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="divider"></div>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Quarterly Interval - Joining Cycle Excluded")}
|
||||
joining_date="15.05.2024"
|
||||
include_joining={false}
|
||||
start_date="01.07.2024"
|
||||
periods={["Q3/2024", "Q4/2024", "Q1/2025"]}
|
||||
note={gettext("Member pays from the next full quarter")}
|
||||
/>
|
||||
<.example_section
|
||||
title={gettext("Quarterly Interval - Joining Cycle Excluded")}
|
||||
joining_date="15.05.2024"
|
||||
include_joining={false}
|
||||
start_date="01.07.2024"
|
||||
periods={["Q3/2024", "Q4/2024", "Q1/2025"]}
|
||||
note={gettext("Member pays from the next full quarter")}
|
||||
/>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="divider"></div>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Monthly Interval - Joining Cycle Included")}
|
||||
joining_date="15.03.2024"
|
||||
include_joining={true}
|
||||
start_date="01.03.2024"
|
||||
periods={["03/2024", "04/2024", "05/2024", "..."]}
|
||||
note={gettext("Member pays from the joining month")}
|
||||
/>
|
||||
<.example_section
|
||||
title={gettext("Monthly Interval - Joining Cycle Included")}
|
||||
joining_date="15.03.2024"
|
||||
include_joining={true}
|
||||
start_date="01.03.2024"
|
||||
periods={["03/2024", "04/2024", "05/2024", "..."]}
|
||||
note={gettext("Member pays from the joining month")}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Fee Types Table --%>
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold mb-4">{gettext("Membership Fee Types")}</h2>
|
||||
<.table
|
||||
id="membership_fee_types"
|
||||
rows={@membership_fee_types}
|
||||
row_id={fn mft -> "mft-#{mft.id}" end}
|
||||
>
|
||||
<:col :let={mft} label={gettext("Name")}>
|
||||
<span class="font-medium">{mft.name}</span>
|
||||
<p :if={mft.description} class="text-sm text-base-content/70">{mft.description}</p>
|
||||
</:col>
|
||||
|
||||
<:col :let={mft} label={gettext("Amount")}>
|
||||
<span class="font-mono">{MembershipFeeHelpers.format_currency(mft.amount)}</span>
|
||||
</:col>
|
||||
|
||||
<:col :let={mft} label={gettext("Interval")}>
|
||||
<span class="badge badge-outline">
|
||||
{MembershipFeeHelpers.format_interval(mft.interval)}
|
||||
</span>
|
||||
</:col>
|
||||
|
||||
<:col :let={mft} label={gettext("Members")}>
|
||||
<span class="badge badge-ghost">{get_member_count(mft, @member_counts)}</span>
|
||||
</:col>
|
||||
|
||||
<:action :let={mft}>
|
||||
<.link
|
||||
navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
|
||||
class="btn btn-ghost btn-xs"
|
||||
aria-label={gettext("Edit membership fee type")}
|
||||
>
|
||||
<.icon name="hero-pencil" class="size-4" />
|
||||
</.link>
|
||||
</:action>
|
||||
|
||||
<:action :let={mft}>
|
||||
<div
|
||||
:if={get_member_count(mft, @member_counts) > 0}
|
||||
class="tooltip tooltip-left"
|
||||
data-tip={
|
||||
gettext("Cannot delete - %{count} member(s) assigned",
|
||||
count: get_member_count(mft, @member_counts)
|
||||
)
|
||||
}
|
||||
>
|
||||
<button
|
||||
phx-click="delete"
|
||||
phx-value-id={mft.id}
|
||||
data-confirm={gettext("Are you sure?")}
|
||||
class="btn btn-ghost btn-xs text-error opacity-50 cursor-not-allowed"
|
||||
aria-label={
|
||||
gettext("Cannot delete - %{count} member(s) assigned",
|
||||
count: get_member_count(mft, @member_counts)
|
||||
)
|
||||
}
|
||||
disabled={true}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
:if={get_member_count(mft, @member_counts) == 0}
|
||||
phx-click="delete"
|
||||
phx-value-id={mft.id}
|
||||
data-confirm={gettext("Are you sure?")}
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
aria-label={gettext("Delete Membership Fee Type")}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
</button>
|
||||
</:action>
|
||||
</.table>
|
||||
|
||||
<details class="mt-6 card bg-base-200">
|
||||
<summary class="card-body cursor-pointer list-none card-title">
|
||||
<.icon name="hero-information-circle" class="size-5" />
|
||||
{gettext("About Membership Fee Types")}
|
||||
</summary>
|
||||
<div class="card-body pt-0 prose prose-sm max-w-none">
|
||||
<p>
|
||||
{gettext(
|
||||
"Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
|
||||
)}
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>{gettext("Name & Amount")}</strong>
|
||||
- {gettext("Can be changed at any time. Amount changes affect future periods only.")}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{gettext("Interval")}</strong>
|
||||
- {gettext(
|
||||
"Fixed after creation. Members can only switch between types with the same interval."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{gettext("Deletion")}</strong>
|
||||
- {gettext("Only possible if no members are assigned to this type.")}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
|
@ -286,6 +456,32 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
defp format_interval(:half_yearly), do: gettext("Half-yearly")
|
||||
defp format_interval(:yearly), do: gettext("Yearly")
|
||||
|
||||
defp load_member_counts(fee_types, actor) do
|
||||
fee_type_ids = Enum.map(fee_types, & &1.id)
|
||||
|
||||
members =
|
||||
Member
|
||||
|> Ash.Query.filter(membership_fee_type_id in ^fee_type_ids)
|
||||
|> Ash.Query.select([:membership_fee_type_id])
|
||||
|> Ash.read!(domain: Membership, actor: actor)
|
||||
|
||||
members
|
||||
|> Enum.group_by(& &1.membership_fee_type_id)
|
||||
|> Enum.map(fn {fee_type_id, members_list} -> {fee_type_id, length(members_list)} end)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp get_member_count(fee_type, member_counts) 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) when is_binary(error), do: error
|
||||
defp format_error(_error), do: gettext("An error occurred")
|
||||
|
||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||
form =
|
||||
AshPhoenix.Form.for_update(
|
||||
|
|
|
|||
|
|
@ -384,7 +384,8 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
|||
defp format_interval_value(value), do: to_string(value)
|
||||
|
||||
@spec return_path(String.t(), MembershipFeeType.t() | nil) :: String.t()
|
||||
defp return_path("index", _membership_fee_type), do: ~p"/membership_fee_types"
|
||||
defp return_path("index", _membership_fee_type), do: ~p"/membership_fee_settings"
|
||||
defp return_path(_, _), do: ~p"/membership_fee_settings"
|
||||
|
||||
@spec get_affected_member_count(String.t(), Mv.Accounts.User.t() | nil) :: non_neg_integer()
|
||||
# Checks if amount changed and updates socket assigns accordingly
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
{gettext("Manage membership fee types for membership fees.")}
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<.button variant="primary" navigate={~p"/membership_fee_types/new"}>
|
||||
<.button variant="primary" navigate={~p"/membership_fee_settings/new_fee_type"}>
|
||||
<.icon name="hero-plus" /> {gettext("New Membership Fee Type")}
|
||||
</.button>
|
||||
</:actions>
|
||||
|
|
@ -79,7 +79,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
|
||||
<:action :let={mft}>
|
||||
<.link
|
||||
navigate={~p"/membership_fee_types/#{mft.id}/edit"}
|
||||
navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
|
||||
class="btn btn-ghost btn-xs"
|
||||
aria-label={gettext("Edit membership fee type")}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -42,9 +42,8 @@ defmodule MvWeb.LiveUserAuth do
|
|||
end
|
||||
|
||||
def on_mount(:live_no_user, _params, session, socket) do
|
||||
# Set the locale for not logged in user to set the language in the Log-In Screen
|
||||
# otherwise the locale is not taken for the Log-In Screen
|
||||
locale = session["locale"] || "en"
|
||||
# Set the locale for not logged in user (default from config, "de" in dev/prod).
|
||||
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
{:cont, assign(socket, :locale, locale)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
defmodule MvWeb.LocaleController do
|
||||
use MvWeb, :controller
|
||||
|
||||
def set_locale(conn, %{"locale" => locale}) do
|
||||
@supported_locales ["de", "en"]
|
||||
|
||||
def set_locale(conn, %{"locale" => locale}) when locale in @supported_locales do
|
||||
conn
|
||||
|> put_session(:locale, locale)
|
||||
# Store locale in a cookie that persists beyond the session
|
||||
|> put_resp_cookie("locale", locale,
|
||||
max_age: 365 * 24 * 60 * 60,
|
||||
same_site: "Lax",
|
||||
|
|
@ -14,6 +15,8 @@ defmodule MvWeb.LocaleController do
|
|||
|> redirect(to: get_referer(conn) || "/")
|
||||
end
|
||||
|
||||
def set_locale(conn, _params), do: redirect(conn, to: get_referer(conn) || "/")
|
||||
|
||||
defp get_referer(conn) do
|
||||
conn.req_headers
|
||||
|> Enum.find(fn {k, _v} -> k == "referer" end)
|
||||
|
|
|
|||
|
|
@ -8,30 +8,30 @@ defmodule MvWeb.PagePaths do
|
|||
|
||||
# Sidebar top-level menu paths
|
||||
@members "/members"
|
||||
@membership_fee_types "/membership_fee_types"
|
||||
@statistics "/statistics"
|
||||
|
||||
# Administration submenu paths (all must match router)
|
||||
@users "/users"
|
||||
@groups "/groups"
|
||||
@admin_roles "/admin/roles"
|
||||
@admin_datafields "/admin/datafields"
|
||||
@membership_fee_settings "/membership_fee_settings"
|
||||
@admin_import "/admin/import"
|
||||
@settings "/settings"
|
||||
|
||||
@admin_page_paths [
|
||||
@users,
|
||||
@groups,
|
||||
@admin_roles,
|
||||
@admin_datafields,
|
||||
@membership_fee_settings,
|
||||
@admin_import,
|
||||
@settings
|
||||
]
|
||||
|
||||
@doc "Path for Members index (sidebar and page permission check)."
|
||||
def members, do: @members
|
||||
|
||||
@doc "Path for Membership Fee Types index (sidebar and page permission check)."
|
||||
def membership_fee_types, do: @membership_fee_types
|
||||
|
||||
@doc "Path for Statistics page (sidebar and page permission check)."
|
||||
def statistics, do: @statistics
|
||||
|
||||
|
|
@ -41,6 +41,8 @@ defmodule MvWeb.PagePaths do
|
|||
def users, do: @users
|
||||
def groups, do: @groups
|
||||
def admin_roles, do: @admin_roles
|
||||
def admin_datafields, do: @admin_datafields
|
||||
def membership_fee_settings, do: @membership_fee_settings
|
||||
def admin_import, do: @admin_import
|
||||
def settings, do: @settings
|
||||
end
|
||||
|
|
|
|||
|
|
@ -68,16 +68,13 @@ defmodule MvWeb.Router do
|
|||
|
||||
live "/settings", GlobalSettingsLive
|
||||
|
||||
# Membership Fee Settings
|
||||
# Membership Fee Settings (includes fee types list; new/edit under sub-routes)
|
||||
live "/membership_fee_settings", MembershipFeeSettingsLive
|
||||
|
||||
# Membership Fee Types Management
|
||||
live "/membership_fee_types", MembershipFeeTypeLive.Index, :index
|
||||
live "/membership_fee_settings/new_fee_type", MembershipFeeTypeLive.Form, :new
|
||||
live "/membership_fee_settings/:id/edit_fee_type", MembershipFeeTypeLive.Form, :edit
|
||||
|
||||
# Statistics
|
||||
live "/statistics", StatisticsLive, :index
|
||||
live "/membership_fee_types/new", MembershipFeeTypeLive.Form, :new
|
||||
live "/membership_fee_types/:id/edit", MembershipFeeTypeLive.Form, :edit
|
||||
|
||||
# Groups Management
|
||||
live "/groups", GroupLive.Index, :index
|
||||
|
|
@ -91,6 +88,9 @@ defmodule MvWeb.Router do
|
|||
live "/admin/roles/:id", RoleLive.Show, :show
|
||||
live "/admin/roles/:id/edit", RoleLive.Form, :edit
|
||||
|
||||
# Datafields (member fields + custom fields)
|
||||
live "/admin/datafields", DatafieldsLive
|
||||
|
||||
# Import (Admin only)
|
||||
live "/admin/import", ImportLive
|
||||
|
||||
|
|
@ -112,7 +112,8 @@ defmodule MvWeb.Router do
|
|||
auth_routes_prefix: "/auth",
|
||||
on_mount: [{MvWeb.LiveUserAuth, :live_no_user}],
|
||||
overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.DaisyUI],
|
||||
gettext_backend: {MvWeb.Gettext, "auth"}
|
||||
gettext_backend: {MvWeb.Gettext, "auth"},
|
||||
live_view: MvWeb.SignInLive
|
||||
|
||||
# Remove this if you do not want to use the reset password feature
|
||||
reset_route auth_routes_prefix: "/auth",
|
||||
|
|
@ -212,8 +213,8 @@ defmodule MvWeb.Router do
|
|||
end)
|
||||
end
|
||||
|
||||
# Our supported languages for now are german and english, english as fallback language
|
||||
# Our supported languages: German and English; default German.
|
||||
defp supported_locale?(locale), do: locale in ["en", "de"]
|
||||
defp fallback_locale(nil), do: "en"
|
||||
defp fallback_locale(nil), do: Application.get_env(:mv, :default_locale, "de")
|
||||
defp fallback_locale(locale), do: locale
|
||||
end
|
||||
|
|
|
|||
|
|
@ -27,8 +27,11 @@ defmodule MvWeb.Translations.MemberFields do
|
|||
def label(:street), do: gettext("Street")
|
||||
def label(:house_number), do: gettext("House Number")
|
||||
def label(:postal_code), do: gettext("Postal Code")
|
||||
def label(:country), do: gettext("Country")
|
||||
def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date")
|
||||
def label(:membership_fee_status), do: gettext("Membership Fee Status")
|
||||
def label(:membership_fee_type), do: gettext("Fee Type")
|
||||
def label(:groups), do: gettext("Groups")
|
||||
|
||||
# Fallback for unknown fields
|
||||
def label(field) do
|
||||
|
|
|
|||
|
|
@ -59,6 +59,12 @@ msgstr ""
|
|||
msgid "Sign in"
|
||||
msgstr ""
|
||||
|
||||
## Dynamic string from ash_authentication_phoenix OAuth2 component (strategy_name = "Oidc").
|
||||
## Not auto-extractable because the msgid is constructed at runtime via string interpolation.
|
||||
## Generated by Phoenix.Naming.humanize(:oidc) = "Oidc"
|
||||
msgid "Sign in with Oidc"
|
||||
msgstr ""
|
||||
|
||||
msgid "Signing in ..."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -131,11 +137,18 @@ msgid "This OIDC account is already linked to another user. Please contact suppo
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Language selection"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select language"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/auth_overrides.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "or"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ msgstr "Neues Passwort setzen"
|
|||
msgid "Sign in"
|
||||
msgstr "Anmelden"
|
||||
|
||||
msgid "Sign in with Oidc"
|
||||
msgstr "Single Sign On"
|
||||
|
||||
msgid "Signing in ..."
|
||||
msgstr "Anmelden..."
|
||||
|
||||
|
|
@ -130,11 +133,18 @@ msgid "This OIDC account is already linked to another user. Please contact suppo
|
|||
msgstr "Dieses OIDC-Konto ist bereits mit einem anderen Benutzer verknüpft. Bitte kontaktiere den Support."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Language selection"
|
||||
msgstr "Sprachauswahl"
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select language"
|
||||
msgstr "Sprache auswählen"
|
||||
|
||||
#: lib/mv_web/auth_overrides.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "or"
|
||||
msgstr "oder"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ msgid "Actions"
|
|||
msgstr "Aktionen"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
|
|
@ -27,6 +28,7 @@ msgid "Are you sure?"
|
|||
msgstr "Bist du sicher?"
|
||||
|
||||
#: lib/mv_web/components/layouts.ex
|
||||
#: lib/mv_web/components/layouts/root.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Attempting to reconnect"
|
||||
msgstr "Verbindung wird wiederhergestellt"
|
||||
|
|
@ -115,11 +117,13 @@ msgid "Show"
|
|||
msgstr "Anzeigen"
|
||||
|
||||
#: lib/mv_web/components/layouts.ex
|
||||
#: lib/mv_web/components/layouts/root.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Something went wrong!"
|
||||
msgstr "Etwas ist schiefgelaufen!"
|
||||
|
||||
#: lib/mv_web/components/layouts.ex
|
||||
#: lib/mv_web/components/layouts/root.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "We can't find the internet"
|
||||
msgstr "Keine Internetverbindung gefunden"
|
||||
|
|
@ -197,6 +201,7 @@ msgstr "Straße"
|
|||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No"
|
||||
|
|
@ -283,8 +288,6 @@ msgstr "Abbrechen"
|
|||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
|
|
@ -320,6 +323,7 @@ msgstr "Benutzer*innen auflisten"
|
|||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -333,6 +337,7 @@ msgstr "Mitglieder"
|
|||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
|
|
@ -380,7 +385,6 @@ msgstr "Alle Mitglieder auswählen"
|
|||
msgid "Select member"
|
||||
msgstr "Mitglied auswählen"
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Settings"
|
||||
|
|
@ -582,6 +586,16 @@ msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte verifiziere dein Pa
|
|||
msgid "Unable to authenticate with OIDC. Please try again."
|
||||
msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuche es erneut."
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The authentication server is currently unavailable. Please try again later."
|
||||
msgstr "Der Authentifizierungsserver ist derzeit nicht erreichbar. Bitte versuche es später erneut."
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Authentication configuration error. Please contact the administrator."
|
||||
msgstr "Authentifizierungskonfigurationsfehler. Bitte kontaktiere den Administrator."
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unable to sign in. Please try again."
|
||||
|
|
@ -830,6 +844,7 @@ msgid "Create Member"
|
|||
msgstr "Mitglied erstellen"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -841,11 +856,13 @@ msgstr "Betrag"
|
|||
msgid "Back to Settings"
|
||||
msgstr "Zurück zu den Einstellungen"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Can be changed at any time. Amount changes affect future periods only."
|
||||
msgstr "Kann jederzeit geändert werden. Änderungen des Betrags betreffen nur zukünftige Zyklen."
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Deletion"
|
||||
|
|
@ -856,6 +873,7 @@ msgstr "Löschen"
|
|||
msgid "Examples"
|
||||
msgstr "Beispiele"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fixed after creation. Members can only switch between types with the same interval."
|
||||
|
|
@ -874,6 +892,7 @@ msgid "Half-yearly"
|
|||
msgstr "Halbjährlich"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -912,11 +931,13 @@ msgstr "Mitglied zahlt ab dem nächsten vollständigen Jahr"
|
|||
msgid "Monthly"
|
||||
msgstr "Monatlich"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Name & Amount"
|
||||
msgstr "Name & Betrag"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Only possible if no members are assigned to this type."
|
||||
|
|
@ -990,7 +1011,7 @@ msgstr "Alle auswählen"
|
|||
msgid "Select none"
|
||||
msgstr "Keine auswählen"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Slug does not match. Deletion cancelled."
|
||||
msgstr "Eingegebener Text war nicht korrekt. Vorgang abgebrochen."
|
||||
|
|
@ -1032,11 +1053,6 @@ msgstr "Textfeld"
|
|||
msgid "Yes/No-Selection"
|
||||
msgstr "Ja/Nein-Auswahl"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Memberdata"
|
||||
msgstr "Mitgliederdaten"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1048,7 +1064,7 @@ msgstr "Optional"
|
|||
msgid "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview."
|
||||
msgstr "Diese Datenfelder sind für MILA notwendig um Mitglieder zu identifizieren und zukünftig Beitragszahlungen zu berechnen. Aus diesem Grund können sie nicht gelöscht, aber in der Übersicht ausgeblendet werden."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member field %{action} successfully"
|
||||
msgstr "Mitgliedsfeld wurde erfolgreich %{action}"
|
||||
|
|
@ -1058,6 +1074,7 @@ msgstr "Mitgliedsfeld wurde erfolgreich %{action}"
|
|||
msgid "A cycle for this period already exists"
|
||||
msgstr "Ein Zyklus für diesen Zeitraum existiert bereits"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "About Membership Fee Types"
|
||||
|
|
@ -1074,6 +1091,7 @@ msgid "Already paid cycles will remain with the old amount."
|
|||
msgstr "Bereits bezahlte Zyklen bleiben mit dem alten Betrag."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#: lib/mv_web/live/role_live/helpers.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1085,6 +1103,7 @@ msgstr "Ein Fehler ist aufgetreten"
|
|||
msgid "Are you sure you want to delete this cycle?"
|
||||
msgstr "Möchtest du diesen Zyklus wirklich löschen?"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cannot delete - %{count} member(s) assigned"
|
||||
|
|
@ -1105,11 +1124,6 @@ msgstr "Die Änderung des Betrags betrifft %{count} Mitglied(er)."
|
|||
msgid "Click to edit amount"
|
||||
msgstr "Klicke, um den Betrag zu bearbeiten"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Configure global settings for membership fees."
|
||||
msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren."
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Confirm Change"
|
||||
|
|
@ -1220,6 +1234,7 @@ msgstr "Feld bearbeiten: %{field}"
|
|||
msgid "Edit Membership Fee Type"
|
||||
msgstr "Mitgliedsbeitragsart bearbeiten"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Edit membership fee type"
|
||||
|
|
@ -1318,6 +1333,7 @@ msgstr "Mitgliedsbeitragsstatus"
|
|||
msgid "Membership Fee Type"
|
||||
msgstr "Mitgliedsbeitragsart"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership Fee Types"
|
||||
|
|
@ -1334,6 +1350,7 @@ msgstr "Mitgliedsbeiträge"
|
|||
msgid "Membership fee start"
|
||||
msgstr "Beitragsbeginn"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee type deleted"
|
||||
|
|
@ -1354,6 +1371,7 @@ msgstr "Mitgliedsbeitragsart erfolgreich gespeichert"
|
|||
msgid "Membership fee type updated. Cycles regenerated."
|
||||
msgstr "Mitgliedsbeitragsart aktualisiert. Zyklen regeneriert."
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
|
||||
|
|
@ -1364,6 +1382,7 @@ msgstr "Mitgliedsbeitragsarten definieren verschiedene Mitgliedsbeitragsstruktur
|
|||
msgid "Monthly Interval - Joining Cycle Included"
|
||||
msgstr "Monatliches Intervall – Beitrittszeitraum einbezogen"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1538,6 +1557,7 @@ msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen"
|
|||
msgid "You are about to delete all %{count} cycles for this member."
|
||||
msgstr "Du bist dabei alle %{count} Zyklen für dieses Mitglied zu löschen."
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete Membership Fee Type"
|
||||
|
|
@ -1559,12 +1579,12 @@ msgstr "Spalten ein-/ausblenden"
|
|||
msgid "Back to settings"
|
||||
msgstr "Zurück zu den Einstellungen"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Data field %{action} successfully"
|
||||
msgstr "Datenfeld erfolgreich %{action}"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Data field deleted successfully"
|
||||
msgstr "Datenfeld erfolgreich gelöscht"
|
||||
|
|
@ -1579,7 +1599,7 @@ msgstr "Datenfeld löschen"
|
|||
msgid "Edit Data Field"
|
||||
msgstr "Datenfeld bearbeiten"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Failed to delete data field: %{error}"
|
||||
msgstr "Konnte Datenfeld nicht löschen: %{error}"
|
||||
|
|
@ -1811,6 +1831,7 @@ msgstr "Zyklus löschen"
|
|||
msgid "The cycle period will be calculated based on this date and the interval."
|
||||
msgstr "Der Zyklus wird basierend auf diesem Datum und dem Intervall berechnet."
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee type not found"
|
||||
|
|
@ -1831,6 +1852,7 @@ msgstr "Benutzer*in erfolgreich gelöscht"
|
|||
msgid "User not found"
|
||||
msgstr "Benutzer*in nicht gefunden"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to access this membership fee type"
|
||||
|
|
@ -1841,6 +1863,7 @@ msgstr "Du hast keine Berechtigung, auf diese Mitgliedsbeitragsart zuzugreifen"
|
|||
msgid "You do not have permission to access this user"
|
||||
msgstr "Du hast keine Berechtigung, auf diese*n Benutzer*in zuzugreifen"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to delete this membership fee type"
|
||||
|
|
@ -1912,16 +1935,6 @@ msgstr "E-Mail ist erforderlich."
|
|||
msgid "Roles"
|
||||
msgstr "Rollen"
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Fee Settings"
|
||||
msgstr "Beitragseinstellungen"
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee Types"
|
||||
msgstr "Beitragstypen"
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Administration"
|
||||
|
|
@ -2037,11 +2050,6 @@ msgstr "Fehlgeschlagen: %{count} Zeile(n)"
|
|||
msgid "German Template"
|
||||
msgstr "Deutsche Vorlage"
|
||||
|
||||
#: lib/mv_web/live/import_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import Members (CSV)"
|
||||
msgstr "Mitglieder importieren (CSV)"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import Results"
|
||||
|
|
@ -2205,6 +2213,7 @@ msgstr "Gruppe erfolgreich gespeichert."
|
|||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/translations/member_fields.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Groups"
|
||||
msgstr "Gruppen"
|
||||
|
|
@ -2259,16 +2268,11 @@ msgstr "Dieser Benutzer kann nicht angezeigt werden."
|
|||
msgid "Not authorized."
|
||||
msgstr "Nicht berechtigt."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Could not load data fields. Please check your permissions."
|
||||
msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfe deine Berechtigungen."
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Could not load member search. Please try again."
|
||||
msgstr "Mitgliedersuche konnte nicht geladen werden. Bitte versuchen Sie es erneut."
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Add Member"
|
||||
|
|
@ -2374,11 +2378,6 @@ msgstr "Du hast keine Berechtigung, auf diese Seite zuzugreifen."
|
|||
msgid "Manage Member Data"
|
||||
msgstr "Mitgliederdaten verwalten"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
|
||||
msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV Datei. Datenfelder müssen in Mila bereits angelegt sein, damit sie importiert werden können. sie müssen in der Liste der Mitgliederdaten als Datenfeld enthalten sein (z.B. E-Mail). Spalten mit unbekannten Spaltenüberschriften werden mit einer Warnung ignoriert."
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export members to CSV"
|
||||
|
|
@ -2415,6 +2414,7 @@ msgstr "Beitragsart auswählen"
|
|||
msgid "Linked"
|
||||
msgstr "Verknüpft"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -2566,7 +2566,7 @@ msgstr "Erstellt am:"
|
|||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export"
|
||||
msgstr "Nach CSV exportieren"
|
||||
msgstr "Export"
|
||||
|
||||
#: lib/mv_web/controllers/member_pdf_export_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -2609,17 +2609,514 @@ msgstr "Import"
|
|||
msgid "Value type cannot be changed after creation"
|
||||
msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden."
|
||||
|
||||
#~ #: lib/mv_web/live/import_export_live.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Export Members (CSV)"
|
||||
#~ msgstr "Mitglieder exportieren (CSV)"
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/translations/member_fields.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Country"
|
||||
msgstr "Land"
|
||||
|
||||
#~ #: lib/mv_web/live/import_export_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Export functionality will be available in a future release."
|
||||
#~ msgstr "Export-Funktionalität ist im nächsten release verfügbar."
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Could not load member list. Please try again."
|
||||
msgstr "Mitgliederliste konnte nicht geladen werden. Bitte versuche es erneut."
|
||||
|
||||
#~ #: lib/mv_web/live/import_export_live.ex
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "API Key"
|
||||
msgstr "API-Schlüssel"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "API URL"
|
||||
msgstr "API-URL"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Club ID"
|
||||
msgstr "Vereins-ID"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From VEREINFACHT_API_KEY"
|
||||
msgstr "Aus VEREINFACHT_API_KEY"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From VEREINFACHT_API_URL"
|
||||
msgstr "Aus VEREINFACHT_API_URL"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From VEREINFACHT_CLUB_ID"
|
||||
msgstr "Aus VEREINFACHT_CLUB_ID"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save Vereinfacht Settings"
|
||||
msgstr "Vereinfacht-Einstellungen speichern"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Sync all members without Vereinfacht contact"
|
||||
msgstr "Alle Mitglieder ohne Vereinfacht-Kontakt synchronisieren"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Synced %{count} member(s) to Vereinfacht."
|
||||
msgstr "%{count} Mitglied(er) mit Vereinfacht synchronisiert."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Syncing..."
|
||||
msgstr "Synchronisiere..."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Vereinfacht Integration"
|
||||
msgstr "Vereinfacht-Integration"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID."
|
||||
msgstr "Vereinfacht ist nicht konfiguriert. Bitte setze API-URL, API-Schlüssel und Vereins-ID."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Test Integration"
|
||||
msgstr "Integration testen"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Testing..."
|
||||
msgstr "Wird getestet..."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection successful. API URL, API Key and Club ID are valid."
|
||||
msgstr "Verbindung erfolgreich. API-URL, API-Schlüssel und Vereins-ID sind korrekt."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not configured. Please set API URL, API Key and Club ID."
|
||||
msgstr "Nicht konfiguriert. Bitte API-URL, API-Schlüssel und Vereins-ID setzen."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed (HTTP %{status}):"
|
||||
msgstr "Verbindung fehlgeschlagen (HTTP %{status}):"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed (HTTP 401): API key is invalid or missing."
|
||||
msgstr "Verbindung fehlgeschlagen (HTTP 401): API-Schlüssel ist ungültig oder fehlt."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed (HTTP 403): Access denied. Please check the Club ID and API key permissions."
|
||||
msgstr "Verbindung fehlgeschlagen (HTTP 403): Zugriff verweigert. Bitte Vereins-ID und Berechtigungen des API-Schlüssels prüfen."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed (HTTP 404): API endpoint not found. Please check the API URL (e.g. correct version path)."
|
||||
msgstr "Verbindung fehlgeschlagen (HTTP 404): API-Endpunkt nicht gefunden. Bitte die API-URL prüfen (z. B. korrekter Versionspfad)."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed. The URL does not point to a Vereinfacht API (received HTML instead of JSON)."
|
||||
msgstr "Verbindung fehlgeschlagen. Die URL zeigt nicht auf eine Vereinfacht-API (HTML statt JSON erhalten)."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed. Could not reach the API (network error or wrong URL)."
|
||||
msgstr "Verbindung fehlgeschlagen. API nicht erreichbar (Netzwerkfehler oder falsche URL)."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed. Unknown error."
|
||||
msgstr "Verbindung fehlgeschlagen. Unbekannter Fehler."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "View contact in Vereinfacht"
|
||||
msgstr "Kontakt in Vereinfacht anzeigen"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{count} failed"
|
||||
msgstr "%{count} fehlgeschlagen"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{count} synced"
|
||||
msgstr "%{count} synchronisiert"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Failed members:"
|
||||
msgstr "Fehlgeschlagene Mitglieder:"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Last sync result:"
|
||||
msgstr "Letztes Sync-Ergebnis:"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Synced %{count} member(s). %{error_count} failed."
|
||||
msgstr "%{count} Mitglied(er) synchronisiert. %{error_count} Fehler."
|
||||
|
||||
# Vereinfacht API error messages (translated for UI)
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Vereinfacht: %{detail}"
|
||||
msgstr "Vereinfacht: %{detail}"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No Vereinfacht contact exists for this member."
|
||||
msgstr "Für dieses Mitglied existiert kein Vereinfacht-Kontakt."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
|
||||
msgstr "Synchronisiere dieses Mitglied unter Einstellungen (Bereich Vereinfacht) oder speichere das Mitglied erneut, um den Kontakt anzulegen."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "(set)"
|
||||
msgstr "(gesetzt)"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Leave blank to keep current"
|
||||
msgstr "Leer lassen, um den aktuellen Wert beizubehalten"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Some values are set via environment variables. Those fields are read-only."
|
||||
msgstr "Einige Werte werden über Umgebungsvariablen gesetzt. Diese Felder sind schreibgeschützt."
|
||||
|
||||
# Vereinfacht API validation messages (looked up at runtime via dgettext)
|
||||
msgid "The address field is required."
|
||||
msgstr "Das Adressfeld ist erforderlich."
|
||||
|
||||
msgid "The city field is required."
|
||||
msgstr "Das Stadtfeld ist erforderlich."
|
||||
|
||||
msgid "The email field is required."
|
||||
msgstr "Das E-Mail-Feld ist erforderlich."
|
||||
|
||||
msgid "The first name field is required."
|
||||
msgstr "Das Vornamenfeld ist erforderlich."
|
||||
|
||||
msgid "The last name field is required."
|
||||
msgstr "Das Nachnamenfeld ist erforderlich."
|
||||
|
||||
msgid "The zip code field is required."
|
||||
msgstr "Das Postleitzahlenfeld ist erforderlich."
|
||||
|
||||
msgid "Too Many Attempts."
|
||||
msgstr "Zu viele Versuche."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "App URL (contact view link)"
|
||||
msgstr "App-URL (Link zur Kontaktansicht)"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "From VEREINFACHT_APP_URL"
|
||||
msgstr "Aus VEREINFACHT_APP_URL"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Error loading receipts: %{reason}"
|
||||
msgstr "Belege konnten nicht geladen werden: %{reason}"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No receipts"
|
||||
msgstr "Keine Belege"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Show bookings/receipts from Vereinfacht"
|
||||
msgstr "Buchungen/Belege aus Vereinfacht anzeigen"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Vereinfacht receipts"
|
||||
msgstr "Vereinfacht-Belege"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancelled"
|
||||
msgstr "Storniert"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Credit"
|
||||
msgstr "Gutschrift"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Credit note"
|
||||
msgstr "Gutschrift"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Draft"
|
||||
msgstr "Entwurf"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invoice"
|
||||
msgstr "Rechnung"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open"
|
||||
msgstr "Offen"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Receipt"
|
||||
msgstr "Beleg"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Apr."
|
||||
msgstr "Apr."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Aug."
|
||||
msgstr "Aug."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Completed"
|
||||
msgstr "Abgeschlossen"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Dec."
|
||||
msgstr "Dez."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Expense"
|
||||
msgstr "Ausgabe"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Feb."
|
||||
msgstr "Feb."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Income"
|
||||
msgstr "Einnahme"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Incompleted"
|
||||
msgstr "Unvollständig"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Jan."
|
||||
msgstr "Jan."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Jul."
|
||||
msgstr "Jul."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Jun."
|
||||
msgstr "Jun."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Mar."
|
||||
msgstr "Mär."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "May"
|
||||
msgstr "Mai"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Nov."
|
||||
msgstr "Nov."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Oct."
|
||||
msgstr "Okt."
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Sep."
|
||||
msgstr "Sep."
|
||||
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Required for Vereinfacht integration and cannot be disabled."
|
||||
msgstr "Für die Vereinfacht-Integration erforderlich und kann nicht deaktiviert werden."
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/translations/member_fields.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee Type"
|
||||
msgstr "Beitragsart"
|
||||
|
||||
#: lib/mv_web/live/import_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import members from CSV files."
|
||||
msgstr "Miglieder aus CSV Dateien importieren."
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
|
||||
msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV Datei. Datenfelder müssen in Mila bereits angelegt sein, da unbekannte Spaltennamen ignoriert werden. Gruppen und der Beitragsstatus kann nicht importiert werden."
|
||||
|
||||
#: lib/mv_web/live/import_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Choose CSV file"
|
||||
msgstr "CSV Datei auswählen"
|
||||
|
||||
#: lib/mv_web/live/import_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Import Members"
|
||||
msgstr "Mitglieder importieren (CSV)"
|
||||
|
||||
#~ #: lib/mv_web/live/import_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Import members from CSV files or export member data."
|
||||
#~ msgstr "Importiere Mitglieder aus CSV-Dateien oder exportiere Mitgliederdaten."
|
||||
#~ msgid "Datei auswählen"
|
||||
#~ msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Admin group name"
|
||||
msgstr "Admin-Gruppenname"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Base URL"
|
||||
msgstr "Basis-URL"
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Basic settings"
|
||||
msgstr "Grundeinstellungen"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Client ID"
|
||||
msgstr "Client-ID"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Client Secret"
|
||||
msgstr "Client-Geheimnis"
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Configure global settings and fee types for membership fees."
|
||||
msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren."
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure member fields and custom data fields."
|
||||
msgstr "Mitgliedsfelder und benutzerdefinierte Datenfelder konfigurieren."
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Custom fields"
|
||||
msgstr "Benutzerdefinierte Felder"
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Datafields"
|
||||
msgstr "Datenfelder"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_ADMIN_GROUP_NAME"
|
||||
msgstr "Aus OIDC_ADMIN_GROUP_NAME"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_BASE_URL"
|
||||
msgstr "Aus OIDC_BASE_URL"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_CLIENT_ID"
|
||||
msgstr "Aus OIDC_CLIENT_ID"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_CLIENT_SECRET"
|
||||
msgstr "Aus OIDC_CLIENT_SECRET"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_GROUPS_CLAIM"
|
||||
msgstr "Aus OIDC_GROUPS_CLAIM"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_REDIRECT_URI"
|
||||
msgstr "Aus OIDC_REDIRECT_URI"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Groups claim"
|
||||
msgstr "Gruppenclaim"
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member fields"
|
||||
msgstr "Mitgliedsfelder"
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership fee settings"
|
||||
msgstr "Beitragseinstellungen"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Redirect URI"
|
||||
msgstr "Weiterleitungs-URI"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save OIDC Settings"
|
||||
msgstr "OIDC-Einstellungen speichern"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "e.g. admin"
|
||||
msgstr "z. B. admin"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_ONLY"
|
||||
msgstr "Aus OIDC_ONLY"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Only OIDC sign-in (hide password login)"
|
||||
msgstr "Nur OIDC-Anmeldung (Passwort-Login ausblenden)"
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
|
||||
msgstr "Wenn aktiviert und OIDC konfiguriert ist, zeigt die Anmeldeseite nur den Single-Sign-On-Button."
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ msgid "Actions"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
|
|
@ -28,6 +29,7 @@ msgid "Are you sure?"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts.ex
|
||||
#: lib/mv_web/components/layouts/root.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Attempting to reconnect"
|
||||
msgstr ""
|
||||
|
|
@ -116,11 +118,13 @@ msgid "Show"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts.ex
|
||||
#: lib/mv_web/components/layouts/root.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Something went wrong!"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts.ex
|
||||
#: lib/mv_web/components/layouts/root.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "We can't find the internet"
|
||||
msgstr ""
|
||||
|
|
@ -198,6 +202,7 @@ msgstr ""
|
|||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No"
|
||||
|
|
@ -284,8 +289,6 @@ msgstr ""
|
|||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
|
|
@ -321,6 +324,7 @@ msgstr ""
|
|||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -334,6 +338,7 @@ msgstr ""
|
|||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
|
|
@ -381,7 +386,6 @@ msgstr ""
|
|||
msgid "Select member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Settings"
|
||||
|
|
@ -583,6 +587,16 @@ msgstr ""
|
|||
msgid "Unable to authenticate with OIDC. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The authentication server is currently unavailable. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Authentication configuration error. Please contact the administrator."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unable to sign in. Please try again."
|
||||
|
|
@ -831,6 +845,7 @@ msgid "Create Member"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -842,11 +857,13 @@ msgstr ""
|
|||
msgid "Back to Settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Can be changed at any time. Amount changes affect future periods only."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Deletion"
|
||||
|
|
@ -857,6 +874,7 @@ msgstr ""
|
|||
msgid "Examples"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fixed after creation. Members can only switch between types with the same interval."
|
||||
|
|
@ -875,6 +893,7 @@ msgid "Half-yearly"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -913,11 +932,13 @@ msgstr ""
|
|||
msgid "Monthly"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Name & Amount"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Only possible if no members are assigned to this type."
|
||||
|
|
@ -991,7 +1012,7 @@ msgstr ""
|
|||
msgid "Select none"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Slug does not match. Deletion cancelled."
|
||||
msgstr ""
|
||||
|
|
@ -1033,11 +1054,6 @@ msgstr ""
|
|||
msgid "Yes/No-Selection"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Memberdata"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1049,7 +1065,7 @@ msgstr ""
|
|||
msgid "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member field %{action} successfully"
|
||||
msgstr ""
|
||||
|
|
@ -1059,6 +1075,7 @@ msgstr ""
|
|||
msgid "A cycle for this period already exists"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "About Membership Fee Types"
|
||||
|
|
@ -1075,6 +1092,7 @@ msgid "Already paid cycles will remain with the old amount."
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#: lib/mv_web/live/role_live/helpers.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1086,6 +1104,7 @@ msgstr ""
|
|||
msgid "Are you sure you want to delete this cycle?"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cannot delete - %{count} member(s) assigned"
|
||||
|
|
@ -1106,11 +1125,6 @@ msgstr ""
|
|||
msgid "Click to edit amount"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure global settings for membership fees."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Confirm Change"
|
||||
|
|
@ -1221,6 +1235,7 @@ msgstr ""
|
|||
msgid "Edit Membership Fee Type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Edit membership fee type"
|
||||
|
|
@ -1319,6 +1334,7 @@ msgstr ""
|
|||
msgid "Membership Fee Type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership Fee Types"
|
||||
|
|
@ -1335,6 +1351,7 @@ 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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee type deleted"
|
||||
|
|
@ -1355,6 +1372,7 @@ msgstr ""
|
|||
msgid "Membership fee type updated. Cycles regenerated."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
|
||||
|
|
@ -1365,6 +1383,7 @@ msgstr ""
|
|||
msgid "Monthly Interval - Joining Cycle Included"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1539,6 +1558,7 @@ msgstr ""
|
|||
msgid "You are about to delete all %{count} cycles for this member."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete Membership Fee Type"
|
||||
|
|
@ -1560,12 +1580,12 @@ msgstr ""
|
|||
msgid "Back to settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Data field %{action} successfully"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Data field deleted successfully"
|
||||
msgstr ""
|
||||
|
|
@ -1580,7 +1600,7 @@ msgstr ""
|
|||
msgid "Edit Data Field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to delete data field: %{error}"
|
||||
msgstr ""
|
||||
|
|
@ -1812,6 +1832,7 @@ msgstr ""
|
|||
msgid "The cycle period will be calculated based on this date and the interval."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee type not found"
|
||||
|
|
@ -1832,6 +1853,7 @@ msgstr ""
|
|||
msgid "User not found"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to access this membership fee type"
|
||||
|
|
@ -1842,6 +1864,7 @@ msgstr ""
|
|||
msgid "You do not have permission to access this user"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You do not have permission to delete this membership fee type"
|
||||
|
|
@ -1913,16 +1936,6 @@ msgstr ""
|
|||
msgid "Roles"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee Settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee Types"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Administration"
|
||||
|
|
@ -2038,11 +2051,6 @@ msgstr ""
|
|||
msgid "German Template"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import Members (CSV)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import Results"
|
||||
|
|
@ -2206,6 +2214,7 @@ msgstr ""
|
|||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/translations/member_fields.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
|
@ -2260,16 +2269,11 @@ msgstr ""
|
|||
msgid "Not authorized."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Could not load data fields. Please check your permissions."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Could not load member search. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Add Member"
|
||||
|
|
@ -2375,11 +2379,6 @@ msgstr ""
|
|||
msgid "Manage Member Data"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export members to CSV"
|
||||
|
|
@ -2416,6 +2415,7 @@ msgstr ""
|
|||
msgid "Linked"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -2609,3 +2609,509 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Value type cannot be changed after creation"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/translations/member_fields.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Country"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Could not load member list. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "API Key"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "API URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Club ID"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From VEREINFACHT_API_KEY"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From VEREINFACHT_API_URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From VEREINFACHT_CLUB_ID"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save Vereinfacht Settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Sync all members without Vereinfacht contact"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Synced %{count} member(s) to Vereinfacht."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Syncing..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Vereinfacht Integration"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Test Integration"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Testing..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection successful. API URL, API Key and Club ID are valid."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not configured. Please set API URL, API Key and Club ID."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed (HTTP %{status}):"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed (HTTP 401): API key is invalid or missing."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed (HTTP 403): Access denied. Please check the Club ID and API key permissions."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed (HTTP 404): API endpoint not found. Please check the API URL (e.g. correct version path)."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed. The URL does not point to a Vereinfacht API (received HTML instead of JSON)."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed. Could not reach the API (network error or wrong URL)."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed. Unknown error."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "View contact in Vereinfacht"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{count} failed"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{count} synced"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed members:"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Last sync result:"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Synced %{count} member(s). %{error_count} failed."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Vereinfacht: %{detail}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No Vereinfacht contact exists for this member."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "(set)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Leave blank to keep current"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Some values are set via environment variables. Those fields are read-only."
|
||||
msgstr ""
|
||||
|
||||
# Vereinfacht API validation messages (looked up at runtime via dgettext)
|
||||
msgid "The address field is required."
|
||||
msgstr ""
|
||||
|
||||
msgid "The city field is required."
|
||||
msgstr ""
|
||||
|
||||
msgid "The email field is required."
|
||||
msgstr ""
|
||||
|
||||
msgid "The first name field is required."
|
||||
msgstr ""
|
||||
|
||||
msgid "The last name field is required."
|
||||
msgstr ""
|
||||
|
||||
msgid "The zip code field is required."
|
||||
msgstr ""
|
||||
|
||||
msgid "Too Many Attempts."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "App URL (contact view link)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From VEREINFACHT_APP_URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Error loading receipts: %{reason}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No receipts"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Show bookings/receipts from Vereinfacht"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Vereinfacht receipts"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancelled"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Credit"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Credit note"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Draft"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invoice"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Receipt"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Apr."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Aug."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Completed"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Dec."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Expense"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Feb."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Income"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Incompleted"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Jan."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Jul."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Jun."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Mar."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "May"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Nov."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Oct."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Sep."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Required for Vereinfacht integration and cannot be disabled."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/translations/member_fields.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee Type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import members from CSV files."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Choose CSV file"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import Members"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Admin group name"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Base URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Basic settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Client ID"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Client Secret"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure global settings and fee types for membership fees."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure member fields and custom data fields."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom fields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Datafields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_ADMIN_GROUP_NAME"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_BASE_URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_CLIENT_ID"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_CLIENT_SECRET"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_GROUPS_CLAIM"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_REDIRECT_URI"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Groups claim"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Member fields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Membership fee settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Redirect URI"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save OIDC Settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "e.g. admin"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_ONLY"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Only OIDC sign-in (hide password login)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@ msgstr ""
|
|||
msgid "Sign in"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sign in with Oidc"
|
||||
msgstr "Single Sign On"
|
||||
|
||||
msgid "Signing in ..."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -127,11 +130,18 @@ msgid "This OIDC account is already linked to another user. Please contact suppo
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Language selection"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select language"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/auth_overrides.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "or"
|
||||
msgstr "or"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ msgid "Actions"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
|
|
@ -28,6 +29,7 @@ msgid "Are you sure?"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts.ex
|
||||
#: lib/mv_web/components/layouts/root.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Attempting to reconnect"
|
||||
msgstr ""
|
||||
|
|
@ -116,11 +118,13 @@ msgid "Show"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts.ex
|
||||
#: lib/mv_web/components/layouts/root.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Something went wrong!"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts.ex
|
||||
#: lib/mv_web/components/layouts/root.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "We can't find the internet"
|
||||
msgstr ""
|
||||
|
|
@ -198,6 +202,7 @@ msgstr ""
|
|||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/role_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No"
|
||||
|
|
@ -284,8 +289,6 @@ msgstr ""
|
|||
#: lib/mv_web/live/group_live/form.ex
|
||||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
#: lib/mv_web/live/role_live/index.html.heex
|
||||
|
|
@ -321,6 +324,7 @@ msgstr ""
|
|||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#: lib/mv_web/live/statistics_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -334,6 +338,7 @@ msgstr ""
|
|||
#: lib/mv_web/live/group_live/show.ex
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#: lib/mv_web/live/role_live/form.ex
|
||||
|
|
@ -381,7 +386,6 @@ msgstr ""
|
|||
msgid "Select member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Settings"
|
||||
|
|
@ -583,6 +587,16 @@ msgstr ""
|
|||
msgid "Unable to authenticate with OIDC. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The authentication server is currently unavailable. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Authentication configuration error. Please contact the administrator."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unable to sign in. Please try again."
|
||||
|
|
@ -831,6 +845,7 @@ msgid "Create Member"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -842,11 +857,13 @@ msgstr ""
|
|||
msgid "Back to Settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Can be changed at any time. Amount changes affect future periods only."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Deletion"
|
||||
|
|
@ -857,6 +874,7 @@ msgstr ""
|
|||
msgid "Examples"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fixed after creation. Members can only switch between types with the same interval."
|
||||
|
|
@ -875,6 +893,7 @@ msgid "Half-yearly"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -913,11 +932,13 @@ msgstr ""
|
|||
msgid "Monthly"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Name & Amount"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Only possible if no members are assigned to this type."
|
||||
|
|
@ -991,7 +1012,7 @@ msgstr ""
|
|||
msgid "Select none"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Slug does not match. Deletion cancelled."
|
||||
msgstr ""
|
||||
|
|
@ -1033,11 +1054,6 @@ msgstr ""
|
|||
msgid "Yes/No-Selection"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Memberdata"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
|
|
@ -1049,7 +1065,7 @@ msgstr ""
|
|||
msgid "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Member field %{action} successfully"
|
||||
msgstr ""
|
||||
|
|
@ -1059,6 +1075,7 @@ msgstr ""
|
|||
msgid "A cycle for this period already exists"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "About Membership Fee Types"
|
||||
|
|
@ -1075,6 +1092,7 @@ msgid "Already paid cycles will remain with the old amount."
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#: lib/mv_web/live/role_live/helpers.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -1086,6 +1104,7 @@ msgstr ""
|
|||
msgid "Are you sure you want to delete this cycle?"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Cannot delete - %{count} member(s) assigned"
|
||||
|
|
@ -1106,11 +1125,6 @@ msgstr ""
|
|||
msgid "Click to edit amount"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Configure global settings for membership fees."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Confirm Change"
|
||||
|
|
@ -1221,6 +1235,7 @@ msgstr ""
|
|||
msgid "Edit Membership Fee Type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Edit membership fee type"
|
||||
|
|
@ -1319,6 +1334,7 @@ msgstr ""
|
|||
msgid "Membership Fee Type"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership Fee Types"
|
||||
|
|
@ -1335,6 +1351,7 @@ 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
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership fee type deleted"
|
||||
|
|
@ -1355,6 +1372,7 @@ msgstr ""
|
|||
msgid "Membership fee type updated. Cycles regenerated."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
|
||||
|
|
@ -1365,6 +1383,7 @@ msgstr ""
|
|||
msgid "Monthly Interval - Joining Cycle Included"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
|
|
@ -1539,6 +1558,7 @@ msgstr ""
|
|||
msgid "You are about to delete all %{count} cycles for this member."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Delete Membership Fee Type"
|
||||
|
|
@ -1560,12 +1580,12 @@ msgstr ""
|
|||
msgid "Back to settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Data field %{action} successfully"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Data field deleted successfully"
|
||||
msgstr ""
|
||||
|
|
@ -1580,7 +1600,7 @@ msgstr ""
|
|||
msgid "Edit Data Field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Failed to delete data field: %{error}"
|
||||
msgstr ""
|
||||
|
|
@ -1812,6 +1832,7 @@ msgstr ""
|
|||
msgid "The cycle period will be calculated based on this date and the interval."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership fee type not found"
|
||||
|
|
@ -1832,6 +1853,7 @@ msgstr ""
|
|||
msgid "User not found"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "You do not have permission to access this membership fee type"
|
||||
|
|
@ -1842,6 +1864,7 @@ msgstr ""
|
|||
msgid "You do not have permission to access this user"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "You do not have permission to delete this membership fee type"
|
||||
|
|
@ -1913,16 +1936,6 @@ msgstr ""
|
|||
msgid "Roles"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Fee Settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee Types"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Administration"
|
||||
|
|
@ -2038,11 +2051,6 @@ msgstr ""
|
|||
msgid "German Template"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import Members (CSV)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import Results"
|
||||
|
|
@ -2206,6 +2214,7 @@ msgstr ""
|
|||
#: lib/mv_web/live/group_live/index.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#: lib/mv_web/translations/member_fields.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
|
@ -2260,16 +2269,11 @@ msgstr ""
|
|||
msgid "Not authorized."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Could not load data fields. Please check your permissions."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Could not load member search. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Add Member"
|
||||
|
|
@ -2375,11 +2379,6 @@ msgstr ""
|
|||
msgid "Manage Member Data"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of member data (like e-mail or first name). Unknown data field columns will be ignored with a warning."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export members to CSV"
|
||||
|
|
@ -2416,6 +2415,7 @@ msgstr ""
|
|||
msgid "Linked"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#: lib/mv_web/live/user_live/index.html.heex
|
||||
#: lib/mv_web/live/user_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -2610,17 +2610,508 @@ msgstr ""
|
|||
msgid "Value type cannot be changed after creation"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/import_export_live.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Export Members (CSV)"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/member_live/form.ex
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/translations/member_fields.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Country"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/import_export_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Export functionality will be available in a future release."
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Could not load member list. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/import_export_live.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Import members from CSV files or export member data."
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "API Key"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "API URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Club ID"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From VEREINFACHT_API_KEY"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From VEREINFACHT_API_URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From VEREINFACHT_CLUB_ID"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Save Vereinfacht Settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Sync all members without Vereinfacht contact"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Synced %{count} member(s) to Vereinfacht."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Syncing..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Vereinfacht Integration"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Test Integration"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Testing..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection successful. API URL, API Key and Club ID are valid."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Not configured. Please set API URL, API Key and Club ID."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed (HTTP %{status}):"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed (HTTP 401): API key is invalid or missing."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed (HTTP 403): Access denied. Please check the Club ID and API key permissions."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed (HTTP 404): API endpoint not found. Please check the API URL (e.g. correct version path)."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed. The URL does not point to a Vereinfacht API (received HTML instead of JSON)."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed. Could not reach the API (network error or wrong URL)."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Connection failed. Unknown error."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "View contact in Vereinfacht"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{count} failed"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "%{count} synced"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Failed members:"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Last sync result:"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Synced %{count} member(s). %{error_count} failed."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Vereinfacht: %{detail}"
|
||||
msgstr "Vereinfacht: %{detail}"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No Vereinfacht contact exists for this member."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
|
||||
msgstr "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "(set)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Leave blank to keep current"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Some values are set via environment variables. Those fields are read-only."
|
||||
msgstr ""
|
||||
|
||||
# Vereinfacht API validation messages (looked up at runtime via dgettext)
|
||||
msgid "The address field is required."
|
||||
msgstr ""
|
||||
|
||||
msgid "The city field is required."
|
||||
msgstr ""
|
||||
|
||||
msgid "The email field is required."
|
||||
msgstr ""
|
||||
|
||||
msgid "The first name field is required."
|
||||
msgstr ""
|
||||
|
||||
msgid "The last name field is required."
|
||||
msgstr ""
|
||||
|
||||
msgid "The zip code field is required."
|
||||
msgstr ""
|
||||
|
||||
msgid "Too Many Attempts."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "App URL (contact view link)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "From VEREINFACHT_APP_URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Error loading receipts: %{reason}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No receipts"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Show bookings/receipts from Vereinfacht"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Vereinfacht receipts"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Cancelled"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Credit"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Credit note"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Draft"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invoice"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Receipt"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Apr."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Aug."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Completed"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Dec."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Expense"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Feb."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Income"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Incompleted"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Jan."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Jul."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Jun."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Mar."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "May"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Nov."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Oct."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Sep."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Required for Vereinfacht integration and cannot be disabled."
|
||||
msgstr "Required for Vereinfacht integration and cannot be disabled."
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/translations/member_fields.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee Type"
|
||||
msgstr "Fee Type"
|
||||
|
||||
#: lib/mv_web/live/import_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Import members from CSV files."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Choose CSV file"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Import Members"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Admin group name"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Base URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Basic settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Client ID"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Client Secret"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Configure global settings and fee types for membership fees."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Configure member fields and custom data fields."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Custom fields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Datafields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_ADMIN_GROUP_NAME"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_BASE_URL"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_CLIENT_ID"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_CLIENT_SECRET"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_GROUPS_CLAIM"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "From OIDC_REDIRECT_URI"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Groups claim"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Member fields"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Membership fee settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Redirect URI"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Save OIDC Settings"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "e.g. admin"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "From OIDC_ONLY"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Only OIDC sign-in (hide password login)"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/global_settings_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -9,7 +9,13 @@
|
|||
#set page(
|
||||
paper: "a4",
|
||||
flipped: true,
|
||||
margin: (top: 1.2cm, bottom: 1.2cm, left: 1.0cm, right: 1.0cm)
|
||||
margin: (top: 1.2cm, bottom: 1.2cm, left: 1.0cm, right: 1.0cm),
|
||||
numbering: "1",
|
||||
footer: context [
|
||||
#set text(size: 8pt)
|
||||
#set align(center)
|
||||
#counter(page).display("1 / 1", both: true)
|
||||
]
|
||||
)
|
||||
|
||||
#set text(size: 9pt, hyphenate: true)
|
||||
|
|
@ -58,7 +64,6 @@
|
|||
#let start = fixed_count + chunk_index * max_dynamic_cols
|
||||
|
||||
#let page_cols = fixed_cols + dyn_cols_chunk
|
||||
#let headers = page_cols.map(c => c.at("label", default: ""))
|
||||
|
||||
// widths: first two columns fixed (32mm, 42mm), rest distributed as 1fr
|
||||
#let widths = (
|
||||
|
|
@ -67,9 +72,9 @@
|
|||
..((1fr,) * dyn_count)
|
||||
)
|
||||
|
||||
#let header_cells = headers.map(h => text(weight: "bold", size: 9pt)[#h])
|
||||
#let header_cells = page_cols.map(c => text(weight: "bold", size: 9pt)[#c.at("label", default: "")])
|
||||
|
||||
// Body cells (row-major), nur die Spalten dieses Chunks
|
||||
// Body cells (row-major), only columns of this chunk
|
||||
#let body_cells = (
|
||||
rows
|
||||
.map(row => row.slice(0, fixed_count) + row.slice(start, start + dyn_count))
|
||||
|
|
@ -77,8 +82,27 @@
|
|||
.flatten()
|
||||
)
|
||||
|
||||
// Thinner grid for body; thicker vertical line between column 2 and 3; header and outer borders thick
|
||||
#let thin_stroke = 0.3pt + black
|
||||
#let thick_sep = 1.5pt + black
|
||||
#let thick_stroke = 1pt + black
|
||||
#let last_x = fixed_count + dyn_count - 1
|
||||
#let last_y = rows.len()
|
||||
#let stroke_fn = (x, y) => {
|
||||
let top = if y == 0 or y == 1 { thick_stroke } else { thin_stroke }
|
||||
let bottom = if y == 0 or y == last_y { thick_stroke } else { thin_stroke }
|
||||
let left = if x == 0 { thick_stroke } else if x == 2 { thick_sep } else { thin_stroke }
|
||||
let right = if x == last_x { thick_stroke } else { thin_stroke }
|
||||
(top: top, bottom: bottom, left: left, right: right)
|
||||
}
|
||||
|
||||
// Light gray background for first two columns (first_name, last_name)
|
||||
#let fill_fn = (x, y) => if x < 2 { rgb("f2f2f2") } else { none }
|
||||
|
||||
#table(
|
||||
columns: widths,
|
||||
stroke: stroke_fn,
|
||||
fill: fill_fn,
|
||||
table.header(..header_cells),
|
||||
..body_cells,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
defmodule Mv.Repo.Migrations.AddVereinfachtContactIdToMembers do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:members) do
|
||||
add :vereinfacht_contact_id, :text
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:members) do
|
||||
remove :vereinfacht_contact_id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
defmodule Mv.Repo.Migrations.AddVereinfachtSettings do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:settings) do
|
||||
add :vereinfacht_api_url, :text
|
||||
add :vereinfacht_api_key, :text
|
||||
add :vereinfacht_club_id, :text
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:settings) do
|
||||
remove :vereinfacht_club_id
|
||||
remove :vereinfacht_api_key
|
||||
remove :vereinfacht_api_url
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
defmodule Mv.Repo.Migrations.AddVereinfachtAppUrl do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:settings) do
|
||||
add :vereinfacht_app_url, :text
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:settings) do
|
||||
remove :vereinfacht_app_url
|
||||
end
|
||||
end
|
||||
end
|
||||
577
priv/repo/migrations/20260223120000_add_country_to_members.exs
Normal file
577
priv/repo/migrations/20260223120000_add_country_to_members.exs
Normal file
|
|
@ -0,0 +1,577 @@
|
|||
defmodule Mv.Repo.Migrations.AddCountryToMembers do
|
||||
@moduledoc """
|
||||
Adds country as an optional member field and includes it in full-text search.
|
||||
|
||||
- Adds :country column to members table (text, nullable)
|
||||
- Updates members_search_vector_trigger() to include country (weight C)
|
||||
- Updates update_member_search_vector_from_custom_field_value() to include country
|
||||
- Updates update_member_search_vector_from_member_groups() to include country
|
||||
- Backfills existing members' search_vector with country
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:members) do
|
||||
add :country, :text
|
||||
end
|
||||
|
||||
# 1. Main trigger on members: add country to search_vector
|
||||
execute("""
|
||||
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
custom_values_text text;
|
||||
groups_text text;
|
||||
BEGIN
|
||||
SELECT string_agg(
|
||||
CASE
|
||||
WHEN value ? '_union_value' THEN value->>'_union_value'
|
||||
WHEN value ? 'value' THEN value->>'value'
|
||||
ELSE ''
|
||||
END,
|
||||
' '
|
||||
)
|
||||
INTO custom_values_text
|
||||
FROM custom_field_values
|
||||
WHERE member_id = NEW.id AND value IS NOT NULL;
|
||||
|
||||
SELECT string_agg(g.name, ' ')
|
||||
INTO groups_text
|
||||
FROM member_groups mg
|
||||
JOIN groups g ON g.id = mg.group_id
|
||||
WHERE mg.member_id = NEW.id;
|
||||
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.country, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B');
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
""")
|
||||
|
||||
# 2. Custom field trigger: include country in recomputed search_vector
|
||||
execute("""
|
||||
CREATE OR REPLACE FUNCTION update_member_search_vector_from_custom_field_value() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
member_id_val uuid;
|
||||
member_first_name text;
|
||||
member_last_name text;
|
||||
member_email text;
|
||||
member_join_date date;
|
||||
member_exit_date date;
|
||||
member_notes text;
|
||||
member_city text;
|
||||
member_street text;
|
||||
member_house_number text;
|
||||
member_postal_code text;
|
||||
member_country text;
|
||||
custom_values_text text;
|
||||
groups_text text;
|
||||
old_value_text text;
|
||||
new_value_text text;
|
||||
BEGIN
|
||||
member_id_val := COALESCE(NEW.member_id, OLD.member_id);
|
||||
|
||||
IF TG_OP = 'UPDATE' THEN
|
||||
old_value_text := COALESCE(
|
||||
NULLIF(OLD.value->>'_union_value', ''),
|
||||
NULLIF(OLD.value->>'value', ''),
|
||||
''
|
||||
);
|
||||
new_value_text := COALESCE(
|
||||
NULLIF(NEW.value->>'_union_value', ''),
|
||||
NULLIF(NEW.value->>'value', ''),
|
||||
''
|
||||
);
|
||||
IF (old_value_text IS NOT DISTINCT FROM new_value_text) AND
|
||||
(OLD.member_id IS NOT DISTINCT FROM NEW.member_id) AND
|
||||
(OLD.custom_field_id IS NOT DISTINCT FROM NEW.custom_field_id) THEN
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
SELECT
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
join_date,
|
||||
exit_date,
|
||||
notes,
|
||||
city,
|
||||
street,
|
||||
house_number,
|
||||
postal_code,
|
||||
country
|
||||
INTO
|
||||
member_first_name,
|
||||
member_last_name,
|
||||
member_email,
|
||||
member_join_date,
|
||||
member_exit_date,
|
||||
member_notes,
|
||||
member_city,
|
||||
member_street,
|
||||
member_house_number,
|
||||
member_postal_code,
|
||||
member_country
|
||||
FROM members
|
||||
WHERE id = member_id_val;
|
||||
|
||||
SELECT string_agg(
|
||||
CASE
|
||||
WHEN value ? '_union_value' THEN value->>'_union_value'
|
||||
WHEN value ? 'value' THEN value->>'value'
|
||||
ELSE ''
|
||||
END,
|
||||
' '
|
||||
)
|
||||
INTO custom_values_text
|
||||
FROM custom_field_values
|
||||
WHERE member_id = member_id_val AND value IS NOT NULL;
|
||||
|
||||
SELECT string_agg(g.name, ' ')
|
||||
INTO groups_text
|
||||
FROM member_groups mg
|
||||
JOIN groups g ON g.id = mg.group_id
|
||||
WHERE mg.member_id = member_id_val;
|
||||
|
||||
UPDATE members
|
||||
SET search_vector =
|
||||
setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_country, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B')
|
||||
WHERE id = member_id_val;
|
||||
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
""")
|
||||
|
||||
# 3. Member groups trigger: include country when refreshing search_vector
|
||||
execute("""
|
||||
CREATE OR REPLACE FUNCTION update_member_search_vector_from_member_groups() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
member_id_val uuid;
|
||||
member_first_name text;
|
||||
member_last_name text;
|
||||
member_email text;
|
||||
member_join_date date;
|
||||
member_exit_date date;
|
||||
member_notes text;
|
||||
member_city text;
|
||||
member_street text;
|
||||
member_house_number text;
|
||||
member_postal_code text;
|
||||
member_country text;
|
||||
custom_values_text text;
|
||||
groups_text text;
|
||||
BEGIN
|
||||
FOR member_id_val IN
|
||||
SELECT COALESCE(NEW.member_id, OLD.member_id)
|
||||
UNION ALL
|
||||
SELECT OLD.member_id
|
||||
WHERE TG_OP = 'UPDATE' AND OLD.member_id IS DISTINCT FROM NEW.member_id
|
||||
LOOP
|
||||
SELECT
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
join_date,
|
||||
exit_date,
|
||||
notes,
|
||||
city,
|
||||
street,
|
||||
house_number,
|
||||
postal_code,
|
||||
country
|
||||
INTO
|
||||
member_first_name,
|
||||
member_last_name,
|
||||
member_email,
|
||||
member_join_date,
|
||||
member_exit_date,
|
||||
member_notes,
|
||||
member_city,
|
||||
member_street,
|
||||
member_house_number,
|
||||
member_postal_code,
|
||||
member_country
|
||||
FROM members
|
||||
WHERE id = member_id_val;
|
||||
|
||||
SELECT string_agg(
|
||||
CASE
|
||||
WHEN value ? '_union_value' THEN value->>'_union_value'
|
||||
WHEN value ? 'value' THEN value->>'value'
|
||||
ELSE ''
|
||||
END,
|
||||
' '
|
||||
)
|
||||
INTO custom_values_text
|
||||
FROM custom_field_values
|
||||
WHERE member_id = member_id_val AND value IS NOT NULL;
|
||||
|
||||
SELECT string_agg(g.name, ' ')
|
||||
INTO groups_text
|
||||
FROM member_groups mg
|
||||
JOIN groups g ON g.id = mg.group_id
|
||||
WHERE mg.member_id = member_id_val;
|
||||
|
||||
UPDATE members
|
||||
SET search_vector =
|
||||
setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_country, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B')
|
||||
WHERE id = member_id_val;
|
||||
END LOOP;
|
||||
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
""")
|
||||
|
||||
# 4. Backfill: update all members' search_vector to include country
|
||||
execute("""
|
||||
UPDATE members m
|
||||
SET search_vector =
|
||||
setweight(to_tsvector('simple', coalesce(m.first_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(m.last_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(m.email, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(m.join_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(m.exit_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(m.notes, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(m.city, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(m.street, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(m.house_number::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(m.postal_code::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(m.country, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(
|
||||
(SELECT string_agg(
|
||||
CASE
|
||||
WHEN value ? '_union_value' THEN value->>'_union_value'
|
||||
WHEN value ? 'value' THEN value->>'value'
|
||||
ELSE ''
|
||||
END,
|
||||
' '
|
||||
)
|
||||
FROM custom_field_values
|
||||
WHERE member_id = m.id AND value IS NOT NULL),
|
||||
''
|
||||
)), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(
|
||||
(SELECT string_agg(g.name, ' ')
|
||||
FROM member_groups mg
|
||||
JOIN groups g ON g.id = mg.group_id
|
||||
WHERE mg.member_id = m.id),
|
||||
''
|
||||
)), 'B')
|
||||
""")
|
||||
end
|
||||
|
||||
def down do
|
||||
# Restore trigger functions without country (revert to previous version from AddGroupNamesToMemberSearchVector)
|
||||
execute("""
|
||||
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
custom_values_text text;
|
||||
groups_text text;
|
||||
BEGIN
|
||||
SELECT string_agg(
|
||||
CASE
|
||||
WHEN value ? '_union_value' THEN value->>'_union_value'
|
||||
WHEN value ? 'value' THEN value->>'value'
|
||||
ELSE ''
|
||||
END,
|
||||
' '
|
||||
)
|
||||
INTO custom_values_text
|
||||
FROM custom_field_values
|
||||
WHERE member_id = NEW.id AND value IS NOT NULL;
|
||||
|
||||
SELECT string_agg(g.name, ' ')
|
||||
INTO groups_text
|
||||
FROM member_groups mg
|
||||
JOIN groups g ON g.id = mg.group_id
|
||||
WHERE mg.member_id = NEW.id;
|
||||
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B');
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE OR REPLACE FUNCTION update_member_search_vector_from_custom_field_value() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
member_id_val uuid;
|
||||
member_first_name text;
|
||||
member_last_name text;
|
||||
member_email text;
|
||||
member_join_date date;
|
||||
member_exit_date date;
|
||||
member_notes text;
|
||||
member_city text;
|
||||
member_street text;
|
||||
member_house_number text;
|
||||
member_postal_code text;
|
||||
custom_values_text text;
|
||||
groups_text text;
|
||||
old_value_text text;
|
||||
new_value_text text;
|
||||
BEGIN
|
||||
member_id_val := COALESCE(NEW.member_id, OLD.member_id);
|
||||
|
||||
IF TG_OP = 'UPDATE' THEN
|
||||
old_value_text := COALESCE(
|
||||
NULLIF(OLD.value->>'_union_value', ''),
|
||||
NULLIF(OLD.value->>'value', ''),
|
||||
''
|
||||
);
|
||||
new_value_text := COALESCE(
|
||||
NULLIF(NEW.value->>'_union_value', ''),
|
||||
NULLIF(NEW.value->>'value', ''),
|
||||
''
|
||||
);
|
||||
IF (old_value_text IS NOT DISTINCT FROM new_value_text) AND
|
||||
(OLD.member_id IS NOT DISTINCT FROM NEW.member_id) AND
|
||||
(OLD.custom_field_id IS NOT DISTINCT FROM NEW.custom_field_id) THEN
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
SELECT
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
join_date,
|
||||
exit_date,
|
||||
notes,
|
||||
city,
|
||||
street,
|
||||
house_number,
|
||||
postal_code
|
||||
INTO
|
||||
member_first_name,
|
||||
member_last_name,
|
||||
member_email,
|
||||
member_join_date,
|
||||
member_exit_date,
|
||||
member_notes,
|
||||
member_city,
|
||||
member_street,
|
||||
member_house_number,
|
||||
member_postal_code
|
||||
FROM members
|
||||
WHERE id = member_id_val;
|
||||
|
||||
SELECT string_agg(
|
||||
CASE
|
||||
WHEN value ? '_union_value' THEN value->>'_union_value'
|
||||
WHEN value ? 'value' THEN value->>'value'
|
||||
ELSE ''
|
||||
END,
|
||||
' '
|
||||
)
|
||||
INTO custom_values_text
|
||||
FROM custom_field_values
|
||||
WHERE member_id = member_id_val AND value IS NOT NULL;
|
||||
|
||||
SELECT string_agg(g.name, ' ')
|
||||
INTO groups_text
|
||||
FROM member_groups mg
|
||||
JOIN groups g ON g.id = mg.group_id
|
||||
WHERE mg.member_id = member_id_val;
|
||||
|
||||
UPDATE members
|
||||
SET search_vector =
|
||||
setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B')
|
||||
WHERE id = member_id_val;
|
||||
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE OR REPLACE FUNCTION update_member_search_vector_from_member_groups() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
member_id_val uuid;
|
||||
member_first_name text;
|
||||
member_last_name text;
|
||||
member_email text;
|
||||
member_join_date date;
|
||||
member_exit_date date;
|
||||
member_notes text;
|
||||
member_city text;
|
||||
member_street text;
|
||||
member_house_number text;
|
||||
member_postal_code text;
|
||||
custom_values_text text;
|
||||
groups_text text;
|
||||
BEGIN
|
||||
FOR member_id_val IN
|
||||
SELECT COALESCE(NEW.member_id, OLD.member_id)
|
||||
UNION ALL
|
||||
SELECT OLD.member_id
|
||||
WHERE TG_OP = 'UPDATE' AND OLD.member_id IS DISTINCT FROM NEW.member_id
|
||||
LOOP
|
||||
SELECT
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
join_date,
|
||||
exit_date,
|
||||
notes,
|
||||
city,
|
||||
street,
|
||||
house_number,
|
||||
postal_code
|
||||
INTO
|
||||
member_first_name,
|
||||
member_last_name,
|
||||
member_email,
|
||||
member_join_date,
|
||||
member_exit_date,
|
||||
member_notes,
|
||||
member_city,
|
||||
member_street,
|
||||
member_house_number,
|
||||
member_postal_code
|
||||
FROM members
|
||||
WHERE id = member_id_val;
|
||||
|
||||
SELECT string_agg(
|
||||
CASE
|
||||
WHEN value ? '_union_value' THEN value->>'_union_value'
|
||||
WHEN value ? 'value' THEN value->>'value'
|
||||
ELSE ''
|
||||
END,
|
||||
' '
|
||||
)
|
||||
INTO custom_values_text
|
||||
FROM custom_field_values
|
||||
WHERE member_id = member_id_val AND value IS NOT NULL;
|
||||
|
||||
SELECT string_agg(g.name, ' ')
|
||||
INTO groups_text
|
||||
FROM member_groups mg
|
||||
JOIN groups g ON g.id = mg.group_id
|
||||
WHERE mg.member_id = member_id_val;
|
||||
|
||||
UPDATE members
|
||||
SET search_vector =
|
||||
setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(groups_text, '')), 'B')
|
||||
WHERE id = member_id_val;
|
||||
END LOOP;
|
||||
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
""")
|
||||
|
||||
# Backfill without country
|
||||
execute("""
|
||||
UPDATE members m
|
||||
SET search_vector =
|
||||
setweight(to_tsvector('simple', coalesce(m.first_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(m.last_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(m.email, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(m.join_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(m.exit_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(m.notes, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(m.city, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(m.street, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(m.house_number::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(m.postal_code::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(
|
||||
(SELECT string_agg(
|
||||
CASE
|
||||
WHEN value ? '_union_value' THEN value->>'_union_value'
|
||||
WHEN value ? 'value' THEN value->>'value'
|
||||
ELSE ''
|
||||
END,
|
||||
' '
|
||||
)
|
||||
FROM custom_field_values
|
||||
WHERE member_id = m.id AND value IS NOT NULL),
|
||||
''
|
||||
)), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(
|
||||
(SELECT string_agg(g.name, ' ')
|
||||
FROM member_groups mg
|
||||
JOIN groups g ON g.id = mg.group_id
|
||||
WHERE mg.member_id = m.id),
|
||||
''
|
||||
)), 'B')
|
||||
""")
|
||||
|
||||
alter table(:members) do
|
||||
remove :country
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
defmodule Mv.Repo.Migrations.AddMemberFieldRequiredToSettings do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:settings) do
|
||||
add :member_field_required, :map
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:settings) do
|
||||
remove :member_field_required
|
||||
end
|
||||
end
|
||||
end
|
||||
29
priv/repo/migrations/20260224122831_add_oidc_to_settings.exs
Normal file
29
priv/repo/migrations/20260224122831_add_oidc_to_settings.exs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
defmodule Mv.Repo.Migrations.AddOidcToSettings do
|
||||
@moduledoc """
|
||||
Adds OIDC configuration columns to settings (ENV-overridable in UI).
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:settings) do
|
||||
add :oidc_client_id, :string
|
||||
add :oidc_base_url, :string
|
||||
add :oidc_redirect_uri, :string
|
||||
add :oidc_client_secret, :string
|
||||
add :oidc_admin_group_name, :string
|
||||
add :oidc_groups_claim, :string
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:settings) do
|
||||
remove :oidc_client_id
|
||||
remove :oidc_base_url
|
||||
remove :oidc_redirect_uri
|
||||
remove :oidc_client_secret
|
||||
remove :oidc_admin_group_name
|
||||
remove :oidc_groups_claim
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
defmodule Mv.Repo.Migrations.AddOidcOnlyToSettings do
|
||||
@moduledoc """
|
||||
Adds oidc_only flag to settings. When true and OIDC is configured,
|
||||
the sign-in page shows only OIDC (password login is hidden).
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:settings) do
|
||||
add :oidc_only, :boolean, default: false, null: false
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:settings) do
|
||||
remove :oidc_only
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -3,10 +3,10 @@
|
|||
# mix run priv/repo/seeds.exs
|
||||
#
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.Accounts
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.Membership
|
||||
alias Mv.MembershipFees.CycleGenerator
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
|
||||
require Ash.Query
|
||||
|
||||
|
|
@ -328,6 +328,7 @@ member_attrs_list = [
|
|||
city: "Berlin",
|
||||
street: "Kastanienallee",
|
||||
house_number: "8",
|
||||
postal_code: "10435",
|
||||
membership_fee_type_id: Enum.at(all_fee_types, 2).id,
|
||||
cycle_status: :mixed
|
||||
},
|
||||
|
|
@ -338,7 +339,8 @@ member_attrs_list = [
|
|||
join_date: ~D[2022-11-10],
|
||||
city: "Berlin",
|
||||
street: "Kastanienallee",
|
||||
house_number: "8"
|
||||
house_number: "8",
|
||||
postal_code: "10435"
|
||||
# No membership_fee_type_id - member without fee type
|
||||
}
|
||||
]
|
||||
|
|
@ -579,6 +581,39 @@ Enum.with_index(linked_members)
|
|||
end
|
||||
end)
|
||||
|
||||
# Create example groups (idempotent: create only if name does not exist)
|
||||
group_configs = [
|
||||
%{name: "Vorstand", description: "Gremium Vorstand"},
|
||||
%{name: "Trainer*innen", description: "Alle lizenzierten Trainer*innen"},
|
||||
%{name: "Jugend", description: "Jugendbereich"},
|
||||
%{name: "Newsletter", description: "Empfänger*innen Newsletter"}
|
||||
]
|
||||
|
||||
existing_groups =
|
||||
case Membership.list_groups(actor: admin_user_with_role) do
|
||||
{:ok, list} -> list
|
||||
{:error, _} -> []
|
||||
end
|
||||
|
||||
existing_names_lower = MapSet.new(existing_groups, &String.downcase(&1.name))
|
||||
|
||||
seed_groups =
|
||||
Enum.reduce(group_configs, %{}, fn config, acc ->
|
||||
name = config.name
|
||||
|
||||
if MapSet.member?(existing_names_lower, String.downcase(name)) do
|
||||
group = Enum.find(existing_groups, &(String.downcase(&1.name) == String.downcase(name)))
|
||||
Map.put(acc, name, group)
|
||||
else
|
||||
group =
|
||||
Membership.create_group!(%{name: name, description: config.description},
|
||||
actor: admin_user_with_role
|
||||
)
|
||||
|
||||
Map.put(acc, name, group)
|
||||
end
|
||||
end)
|
||||
|
||||
# Create sample custom field values for some members
|
||||
all_members = Ash.read!(Membership.Member, actor: admin_user_with_role)
|
||||
all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role)
|
||||
|
|
@ -587,6 +622,35 @@ all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_rol
|
|||
find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end
|
||||
find_member = fn email -> Enum.find(all_members, &(&1.email == email)) end
|
||||
|
||||
# Assign seed members to groups (idempotent: duplicate create_member_group is skipped)
|
||||
member_group_assignments = [
|
||||
{"hans.mueller@example.de", ["Vorstand", "Newsletter"]},
|
||||
{"greta.schmidt@example.de", ["Jugend", "Newsletter"]},
|
||||
{"friedrich.wagner@example.de", ["Trainer*innen"]},
|
||||
{"maria.weber@example.de", ["Newsletter"]},
|
||||
{"thomas.klein@example.de", ["Newsletter"]}
|
||||
]
|
||||
|
||||
Enum.each(member_group_assignments, fn {email, group_names} ->
|
||||
member = find_member.(email)
|
||||
|
||||
if member do
|
||||
Enum.each(group_names, fn group_name ->
|
||||
group = seed_groups[group_name]
|
||||
|
||||
if group do
|
||||
case Membership.create_member_group(
|
||||
%{member_id: member.id, group_id: group.id},
|
||||
actor: admin_user_with_role
|
||||
) do
|
||||
{:ok, _} -> :ok
|
||||
{:error, _} -> :ok
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
|
||||
# Add custom field values for Hans Müller
|
||||
if hans = find_member.("hans.mueller@example.de") do
|
||||
[
|
||||
|
|
@ -731,6 +795,7 @@ IO.puts(
|
|||
)
|
||||
|
||||
IO.puts(" - Sample members: Hans, Greta, Friedrich")
|
||||
IO.puts(" - Groups: Vorstand, Trainer*innen, Jugend, Newsletter (with members assigned)")
|
||||
|
||||
IO.puts(
|
||||
" - Additional users: hans.mueller@example.de, greta.schmidt@example.de, maria.weber@example.de, thomas.klein@example.de"
|
||||
|
|
|
|||
234
priv/resource_snapshots/repo/members/20260218185510.json
Normal file
234
priv/resource_snapshots/repo/members/20260218185510.json
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"uuid_generate_v7()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "first_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "last_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "email",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "join_date",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "exit_date",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "notes",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "city",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "street",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "house_number",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "postal_code",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "search_vector",
|
||||
"type": "tsvector"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "membership_fee_start_date",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "vereinfacht_contact_id",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "members_membership_fee_type_id_fkey",
|
||||
"on_delete": null,
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "membership_fee_types"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "membership_fee_type_id",
|
||||
"type": "uuid"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"create_table_options": null,
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "4DF7F20D4C8D91E229906D6ADF87A4B5EB410672799753012DE4F0F49B470A51",
|
||||
"identities": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "members_unique_email_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "email"
|
||||
}
|
||||
],
|
||||
"name": "unique_email",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "members"
|
||||
}
|
||||
|
|
@ -64,4 +64,4 @@
|
|||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "settings"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,4 +76,4 @@
|
|||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "settings"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,4 +100,4 @@
|
|||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "settings"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
140
priv/resource_snapshots/repo/settings/20260218185541.json
Normal file
140
priv/resource_snapshots/repo/settings/20260218185541.json
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "club_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "member_field_visibility",
|
||||
"type": "map"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "true",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "include_joining_cycle",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "default_membership_fee_type_id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "vereinfacht_api_url",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "vereinfacht_api_key",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "vereinfacht_club_id",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "inserted_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime_usec"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"create_table_options": null,
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "1038A37F021DFC347E325042D613B0359FEB7DAFAE3286CBCEAA940A52B71217",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "settings"
|
||||
}
|
||||
152
priv/resource_snapshots/repo/settings/20260223195453.json
Normal file
152
priv/resource_snapshots/repo/settings/20260223195453.json
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "club_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "member_field_visibility",
|
||||
"type": "map"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "true",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "include_joining_cycle",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "default_membership_fee_type_id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "vereinfacht_api_url",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "vereinfacht_api_key",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "vereinfacht_club_id",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "vereinfacht_app_url",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "inserted_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime_usec"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"create_table_options": null,
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "4C29CEF273C1180162E7231A7F7CCE5DABD035E121648E48B6FBE30AE5191FF0",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "settings"
|
||||
}
|
||||
164
priv/resource_snapshots/repo/settings/20260224122831.json
Normal file
164
priv/resource_snapshots/repo/settings/20260224122831.json
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "club_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "member_field_visibility",
|
||||
"type": "map"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "member_field_required",
|
||||
"type": "map"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "true",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "include_joining_cycle",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "default_membership_fee_type_id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "vereinfacht_api_url",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "vereinfacht_api_key",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "vereinfacht_club_id",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "vereinfacht_app_url",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "inserted_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime_usec"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"create_table_options": null,
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "C84FC81A2A446451D6B5EA72F9BBB3593CD7F0D71C4B7C9CE04934414FDB52EB",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "settings"
|
||||
}
|
||||
|
|
@ -1,2 +1,2 @@
|
|||
Vorname;Nachname;E-Mail;Straße;PLZ;Stadt
|
||||
Max;Mustermann;max.mustermann@example.com;Hauptstraße;10115;Berlin
|
||||
Vorname;Nachname;E-Mail;Land;Stadt;Straße;Hausnummer;PLZ;Beitrittsdatum;Austrittsdatum;Notizen;Beitragsbeginn
|
||||
Max;Mustermann;max.mustermann@example.com;Deutschland;Berlin;Hauptstraße;12;10115;2020-01-15;;;
|
||||
|
|
|
|||
|
|
|
@ -1,2 +1,2 @@
|
|||
first_name;last_name;email;street;postal_code;city
|
||||
John;Doe;john.doe@example.com;Main Street;12345;Berlin
|
||||
first_name;last_name;email;country;city;street;house_number;postal_code;join_date;exit_date;notes;membership_fee_start_date
|
||||
John;Doe;john.doe@example.com;Germany;Berlin;Main Street;1a;12345;2020-01-15;;;
|
||||
|
|
|
|||
|
|
|
@ -103,13 +103,13 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
|||
"preferred_username" => "oidc.user@example.com"
|
||||
}
|
||||
|
||||
# Use sign_in_with_rauthy to find user by oidc_id
|
||||
# Use sign_in_with_oidc to find user by oidc_id
|
||||
# Note: This test will FAIL until we implement the security fix
|
||||
# that changes the filter from email to oidc_id
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(
|
||||
Mv.Accounts.read_sign_in_with_oidc(
|
||||
%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
|
|
@ -145,11 +145,11 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
|||
"preferred_username" => "newuser@example.com"
|
||||
}
|
||||
|
||||
# Should create via register_with_rauthy
|
||||
# Should create via register_with_oidc
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, new_user} =
|
||||
Mv.Accounts.create_register_with_rauthy(
|
||||
Mv.Accounts.create_register_with_oidc(
|
||||
%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
|
|
@ -196,8 +196,8 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
|||
|
||||
describe "Mixed authentication scenarios" do
|
||||
@tag :test_proposal
|
||||
test "user with oidc_id cannot be found by email-only query in sign_in_with_rauthy" do
|
||||
# This test verifies the security fix: sign_in_with_rauthy should NOT
|
||||
test "user with oidc_id cannot be found by email-only query in sign_in_with_oidc" do
|
||||
# This test verifies the security fix: sign_in_with_oidc should NOT
|
||||
# match users by email, only by oidc_id
|
||||
|
||||
_user =
|
||||
|
|
@ -218,7 +218,7 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
|||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(
|
||||
Mv.Accounts.read_sign_in_with_oidc(
|
||||
%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
|
|
@ -238,12 +238,12 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
|||
:ok
|
||||
|
||||
other ->
|
||||
flunk("sign_in_with_rauthy should not match by email alone, got: #{inspect(other)}")
|
||||
flunk("sign_in_with_oidc should not match by email alone, got: #{inspect(other)}")
|
||||
end
|
||||
end
|
||||
|
||||
@tag :test_proposal
|
||||
test "password user (oidc_id=nil) is not found by sign_in_with_rauthy" do
|
||||
test "password user (oidc_id=nil) is not found by sign_in_with_oidc" do
|
||||
# Create a password-only user
|
||||
_user =
|
||||
create_test_user(%{
|
||||
|
|
@ -262,7 +262,7 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
|||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(
|
||||
Mv.Accounts.read_sign_in_with_oidc(
|
||||
%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
|
|
@ -283,7 +283,7 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
|||
|
||||
other ->
|
||||
flunk(
|
||||
"Password-only user should not be found by sign_in_with_rauthy, got: #{inspect(other)}"
|
||||
"Password-only user should not be found by sign_in_with_oidc, got: #{inspect(other)}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ defmodule Mv.Accounts.UserEmailSyncTest do
|
|||
# Simulate OIDC registration
|
||||
{:ok, user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_rauthy, %{
|
||||
|> Ash.Changeset.for_create(:register_with_oidc, %{
|
||||
user_info: user_info,
|
||||
oauth_tokens: oauth_tokens
|
||||
})
|
||||
|
|
|
|||
|
|
@ -80,15 +80,69 @@ defmodule Mv.Membership.MemberTest do
|
|||
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
|
||||
end
|
||||
|
||||
test "Postal code is optional but must have 5 digits if specified", %{actor: actor} do
|
||||
attrs = Map.put(@valid_attrs, :postal_code, "1234")
|
||||
test "Postal code is optional", %{actor: actor} do
|
||||
attrs = Map.delete(@valid_attrs, :postal_code)
|
||||
assert {:ok, _member} = Membership.create_member(attrs, actor: actor)
|
||||
end
|
||||
end
|
||||
|
||||
describe "Settings-driven required fields" do
|
||||
@valid_attrs %{
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
email: "john@example.com"
|
||||
}
|
||||
|
||||
setup do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
saved_visibility = settings.member_field_visibility || %{}
|
||||
saved_required = settings.member_field_required || %{}
|
||||
|
||||
on_exit(fn ->
|
||||
{:ok, s} = Membership.get_settings()
|
||||
|
||||
Membership.update_settings(s, %{
|
||||
member_field_visibility: saved_visibility,
|
||||
member_field_required: saved_required
|
||||
})
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "when first_name is required in settings, create without first_name fails", %{
|
||||
actor: actor
|
||||
} do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
{:ok, _} =
|
||||
Membership.update_single_member_field(settings,
|
||||
field: "first_name",
|
||||
show_in_overview: true,
|
||||
required: true
|
||||
)
|
||||
|
||||
attrs = Map.delete(@valid_attrs, :first_name)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Membership.create_member(attrs, actor: actor)
|
||||
|
||||
assert error_message(errors, :postal_code) =~ "must consist of 5 digits"
|
||||
attrs2 = Map.delete(@valid_attrs, :postal_code)
|
||||
assert {:ok, _member} = Membership.create_member(attrs2, actor: actor)
|
||||
assert error_message(errors, :first_name) =~ "can't be blank"
|
||||
end
|
||||
|
||||
test "when first_name is required in settings, create with first_name succeeds", %{
|
||||
actor: actor
|
||||
} do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
{:ok, _} =
|
||||
Membership.update_single_member_field(settings,
|
||||
field: "first_name",
|
||||
show_in_overview: true,
|
||||
required: true
|
||||
)
|
||||
|
||||
assert {:ok, _member} = Membership.create_member(@valid_attrs, actor: actor)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
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