diff --git a/.drone.yml b/.drone.yml index c97ad2f..dc2aaae 100644 --- a/.drone.yml +++ b/.drone.yml @@ -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: diff --git a/.env.example b/.env.example index d5d35ed..e24b118 100644 --- a/.env.example +++ b/.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 diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 70e1596..50c9eca 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -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 - + <.input field={@form[:first_name]} label={gettext("First Name")} - required + required={@member_field_required_map[:first_name]} aria-required="true" /> ``` diff --git a/Dockerfile b/Dockerfile index 7a01d21..57d296f 100644 --- a/Dockerfile +++ b/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 diff --git a/README.md b/README.md index 94adf08..b35d742 100644 --- a/README.md +++ b/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= - # 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 diff --git a/assets/css/app.css b/assets/css/app.css index b754a08..bbe7424 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -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 */ diff --git a/assets/js/app.js b/assets/js/app.js index 267ae05..de3f154 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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) diff --git a/config/dev.exs b/config/dev.exs index 9af8e74..139b816 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -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 diff --git a/config/runtime.exs b/config/runtime.exs index f1df5b7..93df5bb 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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, diff --git a/config/test.exs b/config/test.exs index fe2b855..864222f 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1ed863a..2c342f9 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/docs/admin-bootstrap-and-oidc-role-sync.md b/docs/admin-bootstrap-and-oidc-role-sync.md index b0da019..abbd03f 100644 --- a/docs/admin-bootstrap-and-oidc-role-sync.md +++ b/docs/admin-bootstrap-and-oidc-role-sync.md @@ -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 diff --git a/docs/csv-member-import-v1.md b/docs/csv-member-import-v1.md index ed5618b..1a717c6 100644 --- a/docs/csv-member-import-v1.md +++ b/docs/csv-member-import-v1.md @@ -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 diff --git a/docs/database-schema-readme.md b/docs/database-schema-readme.md index 6e444a5..f58cbea 100644 --- a/docs/database-schema-readme.md +++ b/docs/database-schema-readme.md @@ -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 diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml index 23605bf..61da063 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -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 ''' } diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 1dcf994..97c586b 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -886,7 +886,7 @@ just regen-migrations **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 diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 41b3d83..b699560 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -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}` | diff --git a/docs/oidc-account-linking.md b/docs/oidc-account-linking.md index 29c2233..570d4e8 100644 --- a/docs/oidc-account-linking.md +++ b/docs/oidc-account-linking.md @@ -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 diff --git a/lib/accounts/accounts.ex b/lib/accounts/accounts.ex index 13218e0..071112a 100644 --- a/lib/accounts/accounts.ex +++ b/lib/accounts/accounts.ex @@ -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 diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 92b9ef2..5e24445 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -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) diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index 411e95d..ef6c79a 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -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 diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 476501c..8f24595 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -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 diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 74735e4..2583718 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -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. diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index bb7d122..894725f 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -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 diff --git a/lib/membership/setting/changes/update_single_member_field.ex b/lib/membership/setting/changes/update_single_member_field.ex new file mode 100644 index 0000000..e24860c --- /dev/null +++ b/lib/membership/setting/changes/update_single_member_field.ex @@ -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 diff --git a/lib/mv/application.ex b/lib/mv/application.ex index ea0c78e..1967ddd 100644 --- a/lib/mv/application.ex +++ b/lib/mv/application.ex @@ -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, diff --git a/lib/mv/authorization/checks/actor_is_system_user.ex b/lib/mv/authorization/checks/actor_is_system_user.ex new file mode 100644 index 0000000..a614a83 --- /dev/null +++ b/lib/mv/authorization/checks/actor_is_system_user.ex @@ -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 diff --git a/lib/mv/config.ex b/lib/mv/config.ex index bcbc8d9..ec69b18 100644 --- a/lib/mv/config.ex +++ b/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 diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 4ef355d..3a01fa9 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -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. diff --git a/lib/mv/membership/import/header_mapper.ex b/lib/mv/membership/import/header_mapper.ex index 709e156..d96d96e 100644 --- a/lib/mv/membership/import/header_mapper.ex +++ b/lib/mv/membership/import/header_mapper.ex @@ -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" ] } diff --git a/lib/mv/membership/import/member_csv.ex b/lib/mv/membership/import/member_csv.ex index c967bf5..23e0d93 100644 --- a/lib/mv/membership/import/member_csv.ex +++ b/lib/mv/membership/import/member_csv.ex @@ -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) diff --git a/lib/mv/membership/member_export.ex b/lib/mv/membership/member_export.ex index e243d40..bbfbb6e 100644 --- a/lib/mv/membership/member_export.ex +++ b/lib/mv/membership/member_export.ex @@ -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) diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex index ce1e98c..9a1c03a 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -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(), diff --git a/lib/mv/membership/members_csv.ex b/lib/mv/membership/members_csv.ex index a0fd463..3d1fdd8 100644 --- a/lib/mv/membership/members_csv.ex +++ b/lib/mv/membership/members_csv.ex @@ -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 diff --git a/lib/mv/oidc_role_sync.ex b/lib/mv/oidc_role_sync.ex index f268154..fbec9de 100644 --- a/lib/mv/oidc_role_sync.ex +++ b/lib/mv/oidc_role_sync.ex @@ -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). diff --git a/lib/mv/oidc_role_sync_config.ex b/lib/mv/oidc_role_sync_config.ex index 493a435..2a8574c 100644 --- a/lib/mv/oidc_role_sync_config.ex +++ b/lib/mv/oidc_role_sync_config.ex @@ -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 diff --git a/lib/mv/secrets.ex b/lib/mv/secrets.ex index ee1519e..177ed90 100644 --- a/lib/mv/secrets.ex +++ b/lib/mv/secrets.ex @@ -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 diff --git a/lib/mv/vereinfacht/changes/sync_contact.ex b/lib/mv/vereinfacht/changes/sync_contact.ex new file mode 100644 index 0000000..99875e0 --- /dev/null +++ b/lib/mv/vereinfacht/changes/sync_contact.ex @@ -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 diff --git a/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex b/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex new file mode 100644 index 0000000..cffb079 --- /dev/null +++ b/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex @@ -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 diff --git a/lib/mv/vereinfacht/client.ex b/lib/mv/vereinfacht/client.ex new file mode 100644 index 0000000..6ec8c8c --- /dev/null +++ b/lib/mv/vereinfacht/client.ex @@ -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 diff --git a/lib/mv/vereinfacht/sync_flash.ex b/lib/mv/vereinfacht/sync_flash.ex new file mode 100644 index 0000000..874a717 --- /dev/null +++ b/lib/mv/vereinfacht/sync_flash.ex @@ -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 diff --git a/lib/mv/vereinfacht/vereinfacht.ex b/lib/mv/vereinfacht/vereinfacht.ex new file mode 100644 index 0000000..6520b64 --- /dev/null +++ b/lib/mv/vereinfacht/vereinfacht.ex @@ -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 diff --git a/lib/mv_web/auth_overrides.ex b/lib/mv_web/auth_overrides.ex index 1367150..f28d81f 100644 --- a/lib/mv_web/auth_overrides.ex +++ b/lib/mv_web/auth_overrides.ex @@ -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 diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 40cb800..21e3546 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -448,6 +448,8 @@ defmodule MvWeb.CoreComponents do end def input(%{type: "select"} = assigns) do + assigns = ensure_aria_required_for_input(assigns) + ~H"""