diff --git a/.drone.yml b/.drone.yml index dc2aaae..c97ad2f 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 --max-cases 2 + - mix test --exclude slow --exclude ui - name: rebuild-cache image: drillster/drone-volume-cache @@ -273,7 +273,7 @@ environment: steps: - name: renovate - image: renovate/renovate:43.35 + image: renovate/renovate:43.31 environment: RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_TOKEN: diff --git a/.env.example b/.env.example index e24b118..d5d35ed 100644 --- a/.env.example +++ b/.env.example @@ -22,22 +22,11 @@ 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/oidc/callback -# OIDC_CLIENT_SECRET=your-oidc-client-secret +# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback +# OIDC_CLIENT_SECRET=your-rauthy-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 50c9eca..70e1596 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -983,9 +983,9 @@ defmodule Mv.Accounts.User do hashed_password_field :hashed_password end - oidc :oidc do + oauth2 :rauthy do client_id fn _, _ -> - Application.fetch_env!(:mv, :oidc)[:client_id] + Application.fetch_env!(:mv, :rauthy)[:client_id] end # ... other config end @@ -1264,8 +1264,6 @@ 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 @@ -1866,7 +1864,7 @@ authentication do hashed_password_field :hashed_password end - oidc :oidc do + oauth2 :rauthy do # OIDC configuration end end @@ -2093,7 +2091,7 @@ plug :protect_from_forgery ```elixir # config/runtime.exs -config :mv, :oidc, +config :mv, :rauthy, 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") @@ -2849,14 +2847,12 @@ 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={@member_field_required_map[:first_name]} + required aria-required="true" /> ``` diff --git a/Dockerfile b/Dockerfile index 57d296f..7a01d21 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=trixie-20260202-slim - for the release image +# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20250317-slim - for the release image # - https://pkgs.org/ - resource for finding needed packages -# - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim +# - Ex: hexpm/elixir:1.18.3-erlang-27.3-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" +ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim" +ARG RUNNER_IMAGE="debian:bullseye-20250317-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 libncurses6 locales ca-certificates \ + apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \ && apt-get clean && rm -f /var/lib/apt/lists/*_* # Set the locale diff --git a/README.md b/README.md index b35d742..94adf08 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/oidc/callback + - redirect uris: http://localhost:4000/auth/user/rauthy/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 `:oidc` — it works with any OIDC-compliant provider. +Mila works with any OIDC-compliant provider. The internal strategy is named `:rauthy`, but this is just a name — it works with any provider. -**Important:** The redirect URI must always end with `/auth/user/oidc/callback`. +**Important:** The redirect URI must always end with `/auth/user/rauthy/callback`. Example for Authentik: 1. Create an OAuth2/OpenID Provider in Authentik -2. Set the redirect URI to: `https://your-domain.com/auth/user/oidc/callback` +2. Set the redirect URI to: `https://your-domain.com/auth/user/rauthy/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/oidc/callback` if not explicitly set. +The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/rauthy/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/oidc/callback + # OIDC_REDIRECT_URI is auto-generated as https://{DOMAIN}/auth/user/rauthy/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 bbe7424..b754a08 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -24,7 +24,7 @@ @plugin "../vendor/daisyui-theme" { name: "dark"; default: false; - prefersdark: false; + prefersdark: true; color-scheme: "dark"; --color-base-100: oklch(30.33% 0.016 252.42); --color-base-200: oklch(25.26% 0.014 253.1); @@ -99,25 +99,6 @@ /* 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 ============================================ */ @@ -357,36 +338,4 @@ } } -/* ============================================ - 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 de3f154..267ae05 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -86,16 +86,6 @@ 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 @@ -238,13 +228,6 @@ 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 139b816..9af8e74 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -93,13 +93,11 @@ config :mv, :secret_key_base, "ryn7D6ssmIHQFWIks2sFiTGATgwwAR1+3bN8p7fy6qVtB8qnx # Signing Secret for Authentication config :mv, :token_signing_secret, "IwUwi65TrEeExwBXXFPGm2I7889NsL" -# 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" +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" # AshAuthentication development configuration config :mv, :session_identifier, :jti diff --git a/config/runtime.exs b/config/runtime.exs index 93df5bb..f1df5b7 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -129,7 +129,8 @@ 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.) - # The redirect_uri callback path is /auth/user/oidc/callback. + # 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. # # 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). @@ -149,9 +150,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/oidc/callback" + default_redirect_uri = "https://#{host}/auth/user/rauthy/callback" - config :mv, :oidc, + config :mv, :rauthy, 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 864222f..fe2b855 100644 --- a/config/test.exs +++ b/config/test.exs @@ -49,9 +49,6 @@ 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 2c342f9..1ed863a 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" - # OIDC config - use host.docker.internal to reach host services + # Rauthy 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/oidc/callback" + OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/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 abbd03f..b0da019 100644 --- a/docs/admin-bootstrap-and-oidc-role-sync.md +++ b/docs/admin-bootstrap-and-oidc-role-sync.md @@ -33,18 +33,14 @@ - `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_oidc after_action calls OidcRoleSync. -2. Sign-in: sign_in_with_oidc prepare after_action calls OidcRoleSync for each user. +1. Registration: register_with_rauthy after_action calls OidcRoleSync. +2. Sign-in: sign_in_with_rauthy 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 1a717c6..ed5618b 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`, `country`) +- Map CSV columns to core member fields (`first_name`, `last_name`, `email`, `street`, `postal_code`, `city`) - **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,26 +149,13 @@ A **basic CSV member import feature** that allows administrators to upload a CSV **v1 Supported Fields:** -**Core Member Fields (all importable):** -- `email` / `E-Mail` (required) +**Core Member Fields:** - `first_name` / `Vorname` (optional) - `last_name` / `Nachname` (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) +- `email` / `E-Mail` (required) - `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). +- `city` / `Stadt` (optional) **Custom Fields:** - Any custom field column using the custom field's **name** as the header (e.g., `membership_number`, `birth_date`) @@ -189,15 +176,9 @@ Address column order in import/export matches the members overview: country, cit | `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 f58cbea..6e444a5 100644 --- a/docs/database-schema-readme.md +++ b/docs/database-schema-readme.md @@ -191,8 +191,7 @@ 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: optional (no format validation) -- Country: optional +- Postal code: 5 digits ### CustomFieldValue System - Maximum one custom field value per custom field per member @@ -241,7 +240,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, country, custom_field_values +- **Weight C:** city, street, house_number, postal_code, 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 61da063..23605bf 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -131,7 +131,6 @@ 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'] @@ -189,8 +188,7 @@ 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: optional (no format validation) - - country: optional + - postal_code: exactly 5 digits (if present) ''' } diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 97c586b..1dcf994 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/oidc/callback` +3. ✅ Redirect URI matches exactly: `http://localhost:4000/auth/user/rauthy/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 b699560..41b3d83 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/oidc` | Initiate OIDC flow | 🔓 | - | Redirect to Rauthy | -| `GET` | `/auth/user/oidc/callback` | Handle OIDC callback | 🔓 | `{code, state}` | 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 | | `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_oidc` | OIDC authentication | 🔓 | `{oidc_id, email, user_info}` | `{:ok, user}` or `{:error, reason}` | +| `User` | `:sign_in_with_rauthy` | 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_oidc` | Create user via OIDC | 🔓 | `{oidc_id, email}` | `{:ok, user}` | +| `User` | `:register_with_rauthy` | 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 570d4e8..29c2233 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_oidc` action now filters by `oidc_id` instead of `email`. +**Change**: The `sign_in_with_rauthy` action now filters by `oidc_id` instead of `email`. ```elixir -read :sign_in_with_oidc do +read :sign_in_with_rauthy 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 071112a..13218e0 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_oidc/1`, `read_sign_in_with_oidc/1` + - Authentication: `create_register_with_rauthy/1`, `read_sign_in_with_rauthy/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_oidc, action: :register_with_oidc - define :read_sign_in_with_oidc, action: :sign_in_with_oidc + define :create_register_with_rauthy, action: :register_with_rauthy + define :read_sign_in_with_rauthy, action: :sign_in_with_rauthy end resource Mv.Accounts.Token diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 5e24445..92b9ef2 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 via OIDC (supports any provider: Authentik, Rauthy, Keycloak, etc.) + Currently password and SSO with Rauthy as OIDC provider """ authentication do session_identifier Application.compile_env!(:mv, :session_identifier) @@ -52,7 +52,7 @@ defmodule Mv.Accounts.User do end strategies do - oidc :oidc do + oidc :rauthy 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_oidc (for OIDC-based registration) + # - :register_with_rauthy (for OIDC-based registration) defaults [:read] destroy :destroy do @@ -118,8 +118,6 @@ 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 @@ -147,8 +145,6 @@ 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 @@ -182,8 +178,6 @@ 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. @@ -217,8 +211,6 @@ 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 @@ -256,8 +248,6 @@ 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 @@ -267,7 +257,7 @@ defmodule Mv.Accounts.User do prepare AshAuthentication.Preparations.FilterBySubject end - read :sign_in_with_oidc do + read :sign_in_with_rauthy do # Single record expected; required for AshAuthentication OAuth2 strategy (returns list of 0 or 1). get? true argument :user_info, :map, allow_nil?: false @@ -302,7 +292,7 @@ defmodule Mv.Accounts.User do end) end - create :register_with_oidc do + create :register_with_rauthy do argument :user_info, :map, allow_nil?: false argument :oauth_tokens, :map, allow_nil?: false upsert? true @@ -338,8 +328,6 @@ 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 ef6c79a..411e95d 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -52,8 +52,7 @@ defmodule Mv.Membership.CustomField do use Ash.Resource, domain: Mv.Membership, data_layer: AshPostgres.DataLayer, - authorizers: [Ash.Policy.Authorizer], - primary_read_warning?: false + authorizers: [Ash.Policy.Authorizer] postgres do table "custom_fields" @@ -61,13 +60,9 @@ 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 8f24595..476501c 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -22,6 +22,7 @@ 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`) @@ -116,9 +117,6 @@ 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, @@ -192,9 +190,6 @@ 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 @@ -248,13 +243,6 @@ 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 @@ -332,12 +320,6 @@ 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. @@ -476,6 +458,11 @@ 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) @@ -494,97 +481,48 @@ defmodule Mv.Membership.Member do end end - # 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 required custom fields (actor from validation context only; no fallback) 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."} - 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")} + {:error, + field: :custom_field_values, + message: + "Unable to validate required custom fields. Please try again or contact support."} end end end @@ -642,10 +580,6 @@ 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, @@ -659,14 +593,6 @@ 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 @@ -1247,8 +1173,7 @@ defmodule Mv.Membership.Member do contains(postal_code, ^query) or contains(house_number, ^query) or contains(email, ^query) or - contains(city, ^query) or - contains(country, ^query) + contains(city, ^query) ) end @@ -1348,24 +1273,17 @@ defmodule Mv.Membership.Member do end end - # 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. + # Extracts custom field values from existing member data (update scenario) defp extract_existing_values(member_data, changeset) do - case Map.get(changeset.context, :actor) do - nil -> + 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) + + _ -> %{} - - 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 @@ -1468,14 +1386,4 @@ 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 2583718..74735e4 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -64,8 +64,6 @@ 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 @@ -259,46 +257,6 @@ 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 894725f..bb7d122 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -11,8 +11,6 @@ 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) @@ -44,9 +42,6 @@ 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}) """ @@ -73,20 +68,8 @@ defmodule Mv.Membership.Setting do accept [ :club_name, :member_field_visibility, - :member_field_required, :include_joining_cycle, - :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 + :default_membership_fee_type_id ] end @@ -97,20 +80,8 @@ defmodule Mv.Membership.Setting do accept [ :club_name, :member_field_visibility, - :member_field_required, :include_joining_cycle, - :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 + :default_membership_fee_type_id ] end @@ -130,17 +101,6 @@ 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 @@ -194,44 +154,6 @@ 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 = @@ -289,12 +211,6 @@ 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 @@ -309,79 +225,6 @@ 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 deleted file mode 100644 index e24860c..0000000 --- a/lib/membership/setting/changes/update_single_member_field.ex +++ /dev/null @@ -1,179 +0,0 @@ -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 1967ddd..ea0c78e 100644 --- a/lib/mv/application.ex +++ b/lib/mv/application.ex @@ -7,8 +7,6 @@ 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 deleted file mode 100644 index a614a83..0000000 --- a/lib/mv/authorization/checks/actor_is_system_user.ex +++ /dev/null @@ -1,15 +0,0 @@ -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 ec69b18..bcbc8d9 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -142,292 +142,4 @@ 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 3a01fa9..4ef355d 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -10,7 +10,6 @@ defmodule Mv.Constants do :join_date, :exit_date, :notes, - :country, :city, :street, :house_number, @@ -28,26 +27,8 @@ 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 d96d96e..709e156 100644 --- a/lib/mv/membership/import/header_mapper.ex +++ b/lib/mv/membership/import/header_mapper.ex @@ -17,24 +17,15 @@ defmodule Mv.Membership.Import.HeaderMapper do ## Member Field Mapping - Maps CSV headers to canonical member fields (same as `Mv.Constants.member_fields()` for - importable attributes). All DB-backed member attributes can be imported. - + Maps CSV headers to canonical member fields: - `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) + - `first_name` (optional) + - `last_name` (optional) + - `street` (optional) + - `postal_code` (optional) + - `city` (optional) - 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. + Supports both English and German variants (e.g., "Email" / "E-Mail", "First Name" / "Vorname"). ## Custom Field Detection @@ -84,37 +75,11 @@ 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", @@ -128,18 +93,6 @@ 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 23e0d93..c967bf5 100644 --- a/lib/mv/membership/import/member_csv.ex +++ b/lib/mv/membership/import/member_csv.ex @@ -549,12 +549,9 @@ 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 = - member_attrs + trimmed_member_attrs |> Map.put(:custom_field_values, custom_field_values) # Only include custom_field_values if not empty @@ -796,23 +793,6 @@ 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 bbfbb6e..e243d40 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_type", "membership_fee_status", "groups"] + ["membership_fee_status"] @computed_export_fields ["membership_fee_status"] @computed_insert_after "membership_fee_start_date" @custom_field_prefix Mv.Constants.custom_field_prefix() @@ -323,14 +323,10 @@ defmodule Mv.Membership.MemberExport do |> Enum.filter(&(&1 in @domain_member_field_strings)) |> order_member_fields_like_table() - # 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 + # final member_fields list (used for column specs order): table order + computed inserted ordered_member_fields = selectable_member_fields - |> insert_fee_type_and_computed_fields_like_table(computed_fields, member_fields) - |> then(fn fields -> fields ++ groups_field end) + |> insert_computed_fields_like_table(computed_fields) %{ selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")), @@ -420,52 +416,27 @@ defmodule Mv.Membership.MemberExport do table_order |> Enum.filter(&(&1 in fields)) end - defp insert_fee_type_and_computed_fields_like_table( - db_fields_ordered, - computed_fields, - member_fields - ) do + 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. computed_fields = computed_fields || [] - member_fields = member_fields || [] db_with_insert = Enum.flat_map(db_fields_ordered, fn f -> - expand_field_with_computed(f, member_fields, computed_fields) + if f == @computed_insert_after and "membership_fee_status" in computed_fields do + [f, "membership_fee_status"] + else + [f] + end end) - # 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 = + computed_fields + |> Enum.reject(&(&1 in db_with_insert)) - 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 9a1c03a..ce1e98c 100644 --- a/lib/mv/membership/member_export/build.ex +++ b/lib/mv/membership/member_export/build.ex @@ -132,20 +132,12 @@ 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 @@ -201,10 +193,8 @@ 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() 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) + if field_atom in Mv.Constants.member_fields() do + sort_by_field(members, field_atom, order) else members end @@ -214,16 +204,12 @@ defmodule Mv.Membership.MemberExport.Build do defp sort_members_in_memory(members, _field, _order), do: members - 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_by_field(members, field_atom, order) do + key_fn = fn member -> Map.get(member, field_atom) end + compare_fn = build_compare_fn(order) - defp sort_key_fn_for_field(field_atom), do: fn member -> Map.get(member, field_atom) end + Enum.sort_by(members, key_fn, compare_fn) + 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 @@ -255,65 +241,30 @@ 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 - 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) + if custom_field_sort?(field) do + {query, true} + else + field_atom = String.to_existing_atom(field) + + if field_atom in (Mv.Constants.member_fields() -- [:notes]) do + {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false} + else + {query, false} + end end rescue ArgumentError -> {query, false} end - defp 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 - else - sort_members_with_custom_field(members, custom_field, order) - end - end + if is_nil(custom_field), do: members - 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 @@ -326,26 +277,6 @@ 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 -> @@ -363,19 +294,6 @@ 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 @@ -425,32 +343,6 @@ 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 -> @@ -469,8 +361,7 @@ defmodule Mv.Membership.MemberExport.Build do end) |> Enum.reject(&is_nil/1) - # 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 + member_cols ++ computed_cols ++ custom_cols end defp build_rows(members, columns, custom_fields_by_id) do @@ -500,22 +391,6 @@ 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 @@ -549,15 +424,6 @@ 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 3d1fdd8..a0fd463 100644 --- a/lib/mv/membership/members_csv.ex +++ b/lib/mv/membership/members_csv.ex @@ -59,18 +59,6 @@ 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 @@ -109,13 +97,4 @@ 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 fbec9de..f268154 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_oidc) and on sign-in so that + Used after OIDC registration (register_with_rauthy) 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 2a8574c..493a435 100644 --- a/lib/mv/oidc_role_sync_config.ex +++ b/lib/mv/oidc_role_sync_config.ex @@ -2,19 +2,23 @@ defmodule Mv.OidcRoleSyncConfig do @moduledoc """ Runtime configuration for OIDC group → role sync (e.g. admin group → Admin role). - Reads from Mv.Config (ENV first, then Settings): - - `oidc_admin_group_name/0` – OIDC group name that maps to Admin role (optional; when nil, no sync). - - `oidc_groups_claim/0` – JWT/user_info claim name for groups (default: `"groups"`). + 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"`). - Set via ENV: OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM; or via Settings (Basic settings → OIDC). + Set via ENV in production: OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM (see config/runtime.exs). """ @doc "Returns the OIDC group name that maps to Admin role, or nil if not configured." def oidc_admin_group_name do - Mv.Config.oidc_admin_group_name() + get(:admin_group_name) end @doc "Returns the JWT/user_info claim name for groups; defaults to \"groups\"." def oidc_groups_claim do - Mv.Config.oidc_groups_claim() || "groups" + get(:groups_claim) || "groups" + end + + defp get(key) do + Application.get_env(:mv, :oidc_role_sync, []) |> Keyword.get(key) end end diff --git a/lib/mv/secrets.ex b/lib/mv/secrets.ex index 177ed90..ee1519e 100644 --- a/lib/mv/secrets.ex +++ b/lib/mv/secrets.ex @@ -7,66 +7,59 @@ defmodule Mv.Secrets do particularly for OIDC (Rauthy) authentication. ## Configuration Source - 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 + 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` - 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. + ## Usage + This module is automatically called by AshAuthentication when resolving + secrets for the User resource's OIDC strategy. """ use AshAuthentication.Secret - alias AshAuthentication.Errors.MissingSecret - def secret_for( - [:authentication, :strategies, :oidc, :client_id], - resource, + [:authentication, :strategies, :rauthy, :client_id], + Mv.Accounts.User, _opts, _meth ) do - secret_or_error(Mv.Config.oidc_client_id(), resource, :client_id) + get_config(:client_id) end def secret_for( - [:authentication, :strategies, :oidc, :redirect_uri], - resource, + [:authentication, :strategies, :rauthy, :redirect_uri], + Mv.Accounts.User, _opts, _meth ) do - secret_or_error(Mv.Config.oidc_redirect_uri(), resource, :redirect_uri) + get_config(:redirect_uri) end def secret_for( - [:authentication, :strategies, :oidc, :client_secret], - resource, + [:authentication, :strategies, :rauthy, :client_secret], + Mv.Accounts.User, _opts, _meth ) do - secret_or_error(Mv.Config.oidc_client_secret(), resource, :client_secret) + get_config(:client_secret) end def secret_for( - [:authentication, :strategies, :oidc, :base_url], - resource, + [:authentication, :strategies, :rauthy, :base_url], + Mv.Accounts.User, _opts, _meth ) do - secret_or_error(Mv.Config.oidc_base_url(), resource, :base_url) + get_config(:base_url) end - 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 + defp get_config(key) do + :mv + |> Application.fetch_env!(:rauthy) + |> Keyword.fetch!(key) + |> then(&{:ok, &1}) end end diff --git a/lib/mv/vereinfacht/changes/sync_contact.ex b/lib/mv/vereinfacht/changes/sync_contact.ex deleted file mode 100644 index 99875e0..0000000 --- a/lib/mv/vereinfacht/changes/sync_contact.ex +++ /dev/null @@ -1,91 +0,0 @@ -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 deleted file mode 100644 index cffb079..0000000 --- a/lib/mv/vereinfacht/changes/sync_linked_member_after_user_change.ex +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index 6ec8c8c..0000000 --- a/lib/mv/vereinfacht/client.ex +++ /dev/null @@ -1,423 +0,0 @@ -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 deleted file mode 100644 index 874a717..0000000 --- a/lib/mv/vereinfacht/sync_flash.ex +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 6520b64..0000000 --- a/lib/mv/vereinfacht/vereinfacht.ex +++ /dev/null @@ -1,186 +0,0 @@ -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 f28d81f..1367150 100644 --- a/lib/mv_web/auth_overrides.ex +++ b/lib/mv_web/auth_overrides.ex @@ -38,16 +38,11 @@ defmodule MvWeb.AuthOverrides do set :image_url, nil end - # 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). + # Translate the or in the horizontal rule to German override AshAuthentication.Phoenix.Components.HorizontalRule do - 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" + set :text, + Gettext.with_locale(MvWeb.Gettext, "de", fn -> + Gettext.gettext(MvWeb.Gettext, "or") + end) end end diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 21e3546..40cb800 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -448,8 +448,6 @@ defmodule MvWeb.CoreComponents do end def input(%{type: "select"} = assigns) do - assigns = ensure_aria_required_for_input(assigns) - ~H"""