diff --git a/.drone.yml b/.drone.yml index 623114f..483a08a 100644 --- a/.drone.yml +++ b/.drone.yml @@ -53,6 +53,8 @@ steps: - mix hex.audit # Provide hints for improving code quality - mix credo + # Check that translations are up to date + - mix gettext.extract --check-up-to-date - name: wait_for_postgres image: docker.io/library/postgres:17.6 @@ -164,7 +166,7 @@ environment: steps: - name: renovate - image: renovate/renovate:41.151 + image: renovate/renovate:41.173 environment: RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_TOKEN: diff --git a/.env.example b/.env.example index 7559b0a..13154f3 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,9 @@ TOKEN_SIGNING_SECRET=changeme-run-mix-phx.gen.secret # Required: Hostname for URL generation PHX_HOST=localhost +# Recommended: Association settings +ASSOCIATION_NAME="Sportsclub XYZ" + # Optional: OIDC Configuration # These have defaults in docker-compose.prod.yml, only override if needed # OIDC_CLIENT_ID=mv diff --git a/.gitignore b/.gitignore index 63ff39e..9517a21 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ npm-debug.log .env .elixir_ls/ + +# Docker secrets directory (generated by `just init-secrets`) +/secrets/ diff --git a/.tool-versions b/.tool-versions index 60315fc..98239f3 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ elixir 1.18.3-otp-27 erlang 27.3.4 -just 1.43.0 +just 1.43.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 74df997..28b4a37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - PostgreSQL trigram-based member search with typo tolerance - WCAG 2.1 AA compliant autocomplete dropdown with ARIA support - Bilingual UI (German/English) for member linking workflow +- **Bulk email copy feature** - Copy email addresses of selected members to clipboard (#230) + - Email format: "First Last " with semicolon separator (compatible with email clients) + - CopyToClipboard JavaScript hook with fallback for older browsers + - Button shows count of visible selected members (respects search/filter) + - German/English translations +- Docker secrets support via `_FILE` environment variables for all sensitive configuration (SECRET_KEY_BASE, TOKEN_SIGNING_SECRET, OIDC_CLIENT_SECRET, DATABASE_URL, DATABASE_PASSWORD) ### Fixed - Email validation false positive when linking user and member with identical emails (#168 Problem #4) - Relationship data extraction from Ash manage_relationship during validation +- Copy button count now shows only visible selected members when filtering diff --git a/Justfile b/Justfile index b28dbdc..b835cf4 100644 --- a/Justfile +++ b/Justfile @@ -1,4 +1,7 @@ set dotenv-load := true +set export := true + +MIX_QUIET := "1" run: install-dependencies start-database migrate-database seed-database mix phx.server @@ -29,6 +32,7 @@ lint: mix format --check-formatted mix compile --warnings-as-errors mix credo + mix gettext.extract --check-up-to-date audit: mix sobelow --config @@ -83,4 +87,33 @@ regen-migrations migration_name commit_hash='': clean: mix clean rm -rf .elixir_ls - rm -rf _build \ No newline at end of file + rm -rf _build + +# Remove Git merge conflict markers from gettext files +remove-gettext-conflicts: + #!/usr/bin/env bash + set -euo pipefail + find priv/gettext -type f -exec sed -i '/^<<<<<<>>>>>>/d; /^%%%%%%%/d; /^++++++/d; s/^+//; s/^-//' {} \; + +# Production environment commands +# ================================ + +# Initialize secrets directory with generated secrets (only if not exists) +init-prod-secrets: + #!/usr/bin/env bash + set -euo pipefail + if [ -d "secrets" ]; then + echo "Secrets directory already exists. Skipping generation." + exit 0 + fi + echo "Creating secrets directory and generating secrets..." + mkdir -p secrets + mix phx.gen.secret > secrets/secret_key_base.txt + mix phx.gen.secret > secrets/token_signing_secret.txt + openssl rand -base64 32 | tr -d '\n' > secrets/db_password.txt + touch secrets/oidc_client_secret.txt + echo "Secrets generated in ./secrets/" + +# Start production environment with Docker Compose +start-prod: init-prod-secrets + docker compose -f docker-compose.prod.yml up -d \ No newline at end of file diff --git a/README.md b/README.md index 6db7980..090f4e9 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Our philosophy: **software should help people spend less time on administration - 🚧 Sorting & filtering - 🚧 Roles & permissions (e.g. board, treasurer) - ✅ Custom fields (flexible per club needs) -- ✅ SSO via OIDC (tested with Rauthy) +- ✅ SSO via OIDC (works with Authentik, Rauthy, Keycloak, etc.) - 🚧 Self-service & online application - 🚧 Accessibility, GDPR, usability improvements - 🚧 Email sending @@ -147,7 +147,26 @@ Mila uses OIDC for Single Sign-On. In development, a local **Rauthy** instance i 5. copy client secret to `.env` file 6. abort and run `just run` again -Now you can log in to Mila via OIDC! +Now you can log in to Mila via OIDC! + +### OIDC with other providers (Authentik, Keycloak, etc.) + +Mila works with any OIDC-compliant provider. The internal strategy is named `:rauthy`, but this is just a name — it works with any provider. + +**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/rauthy/callback` +3. Configure environment variables: + ```bash + DOMAIN=your-domain.com # or PHX_HOST=your-domain.com + OIDC_CLIENT_ID=your-client-id + OIDC_BASE_URL=https://auth.example.com/application/o/your-app + OIDC_CLIENT_SECRET=your-client-secret # or use OIDC_CLIENT_SECRET_FILE + ``` + +The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/rauthy/callback` if not explicitly set. ## ⚙️ Configuration @@ -210,13 +229,20 @@ For testing the production Docker build locally: # Required variables: SECRET_KEY_BASE= TOKEN_SIGNING_SECRET= - PHX_HOST=localhost + DOMAIN=localhost # or PHX_HOST=localhost - # Optional (have defaults in docker-compose.prod.yml): + # Optional OIDC configuration: # OIDC_CLIENT_ID=mv # OIDC_BASE_URL=http://localhost:8080/auth/v1 - # OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback - # OIDC_CLIENT_SECRET= + # OIDC_CLIENT_SECRET= + # 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 + # TOKEN_SIGNING_SECRET_FILE=/run/secrets/token_signing_secret + # OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret + # DATABASE_URL_FILE=/run/secrets/database_url + # DATABASE_PASSWORD_FILE=/run/secrets/database_password ``` 3. **Start development environment** (for Rauthy): @@ -250,7 +276,7 @@ For actual production deployment: - Set `OIDC_BASE_URL` to your production OIDC provider - Configure proper Docker networks 3. **Set up SSL/TLS** (e.g., via reverse proxy like Nginx/Traefik) -4. **Use secure secrets management** (environment variables, Docker secrets, vault) +4. **Use secure secrets management** — All sensitive environment variables support a `_FILE` suffix for Docker secrets (e.g., `SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base`). See `docker-compose.prod.yml` for an example setup with Docker secrets. 5. **Configure database backups** diff --git a/assets/js/app.js b/assets/js/app.js index e55a06d..883ca30 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -27,6 +27,33 @@ let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute(" // Hooks for LiveView components let Hooks = {} +// CopyToClipboard hook: Copies text to clipboard when triggered by server event +Hooks.CopyToClipboard = { + mounted() { + this.handleEvent("copy_to_clipboard", ({text}) => { + if (navigator.clipboard) { + navigator.clipboard.writeText(text).catch(err => { + console.error("Clipboard write failed:", err) + }) + } else { + // Fallback for older browsers + const textArea = document.createElement("textarea") + textArea.value = text + textArea.style.position = "fixed" + textArea.style.left = "-999999px" + document.body.appendChild(textArea) + textArea.select() + try { + document.execCommand("copy") + } catch (err) { + console.error("Fallback clipboard copy failed:", err) + } + document.body.removeChild(textArea) + } + }) + } +} + // ComboBox hook: Prevents form submission when Enter is pressed in dropdown Hooks.ComboBox = { mounted() { diff --git a/config/runtime.exs b/config/runtime.exs index c50356c..06a2cd8 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -7,6 +7,75 @@ import Config # any compile-time configuration in here, as it won't be applied. # The block below contains prod specific runtime configuration. +# Helper function to read environment variables with Docker secrets support. +# Supports the _FILE suffix pattern: if VAR_FILE is set, reads the value from +# that file path. Otherwise falls back to VAR directly. +# VAR_FILE takes priority and must contain the full absolute path to the secret file. +get_env_or_file = fn var_name, default -> + file_var = "#{var_name}_FILE" + + case System.get_env(file_var) do + nil -> + System.get_env(var_name, default) + + file_path -> + case File.read(file_path) do + {:ok, content} -> + String.trim_trailing(content) + + {:error, reason} -> + raise """ + Failed to read secret from file specified in #{file_var}="#{file_path}". + Error: #{inspect(reason)} + """ + end + end +end + +# Same as get_env_or_file but raises if the value is not set +get_env_or_file! = fn var_name, error_message -> + case get_env_or_file.(var_name, nil) do + nil -> raise error_message + value -> value + end +end + +# Build database URL from individual components or use DATABASE_URL directly. +# Supports both approaches: +# 1. DATABASE_URL (or DATABASE_URL_FILE) - full connection URL +# 2. Separate vars: DATABASE_HOST, DATABASE_USER, DATABASE_PASSWORD (or _FILE), DATABASE_NAME, DATABASE_PORT +build_database_url = fn -> + case get_env_or_file.("DATABASE_URL", nil) do + nil -> + # Build URL from separate components + host = + System.get_env("DATABASE_HOST") || + raise "DATABASE_HOST is required when DATABASE_URL is not set" + + user = + System.get_env("DATABASE_USER") || + raise "DATABASE_USER is required when DATABASE_URL is not set" + + password = + get_env_or_file!.("DATABASE_PASSWORD", """ + DATABASE_PASSWORD or DATABASE_PASSWORD_FILE is required when DATABASE_URL is not set. + """) + + database = + System.get_env("DATABASE_NAME") || + raise "DATABASE_NAME is required when DATABASE_URL is not set" + + port = System.get_env("DATABASE_PORT", "5432") + + # URL-encode the password to handle special characters + encoded_password = URI.encode_www_form(password) + "ecto://#{user}:#{encoded_password}@#{host}:#{port}/#{database}" + + url -> + url + end +end + # ## Using releases # # If you use `mix release`, you need to explicitly enable the server @@ -21,12 +90,7 @@ if System.get_env("PHX_SERVER") do end if config_env() == :prod do - database_url = - System.get_env("DATABASE_URL") || - raise """ - environment variable DATABASE_URL is missing. - For example: ecto://USER:PASS@HOST/DATABASE - """ + database_url = build_database_url.() maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] @@ -41,45 +105,72 @@ if config_env() == :prod do # want to use a different value for prod and you most likely don't want # to check this value into version control, so we use an environment # variable instead. + # Supports SECRET_KEY_BASE or SECRET_KEY_BASE_FILE for Docker secrets. secret_key_base = - System.get_env("SECRET_KEY_BASE") || - raise """ - environment variable SECRET_KEY_BASE is missing. - You can generate one by calling: mix phx.gen.secret - """ + get_env_or_file!.("SECRET_KEY_BASE", """ + environment variable SECRET_KEY_BASE (or SECRET_KEY_BASE_FILE) is missing. + You can generate one by calling: mix phx.gen.secret + """) + + # PHX_HOST or DOMAIN can be used to set the host for the application. + # DOMAIN is commonly used in deployment environments (e.g., Portainer templates). + host = + System.get_env("PHX_HOST") || + System.get_env("DOMAIN") || + raise "Please define the PHX_HOST or DOMAIN environment variable." - host = System.get_env("PHX_HOST") || raise "Please define the PHX_HOST environment variable." port = String.to_integer(System.get_env("PORT") || "4000") config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") - # Rauthy OIDC configuration + # OIDC configuration (works with any OIDC provider: Authentik, Rauthy, Keycloak, etc.) + # Note: The strategy is named :rauthy internally, but works with any OIDC provider. + # The redirect_uri callback path is always /auth/user/rauthy/callback regardless of provider. + # + # 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). + oidc_base_url = System.get_env("OIDC_BASE_URL") + oidc_client_id = System.get_env("OIDC_CLIENT_ID") + oidc_in_use = not is_nil(oidc_base_url) or not is_nil(oidc_client_id) + + client_secret = + if oidc_in_use do + get_env_or_file!.("OIDC_CLIENT_SECRET", """ + environment variable OIDC_CLIENT_SECRET (or OIDC_CLIENT_SECRET_FILE) is missing. + This is required when OIDC authentication is configured (OIDC_BASE_URL or OIDC_CLIENT_ID is set). + """) + else + get_env_or_file.("OIDC_CLIENT_SECRET", nil) + end + + # Build redirect_uri: use OIDC_REDIRECT_URI if set, otherwise build from host. + # Uses HTTPS since production runs behind TLS termination. + default_redirect_uri = "https://#{host}/auth/user/rauthy/callback" + config :mv, :rauthy, - client_id: System.get_env("OIDC_CLIENT_ID") || "mv", - base_url: System.get_env("OIDC_BASE_URL") || "http://localhost:8080/auth/v1", - client_secret: System.get_env("OIDC_CLIENT_SECRET"), - redirect_uri: - System.get_env("OIDC_REDIRECT_URI") || "http://#{host}:#{port}/auth/user/rauthy/callback" + client_id: oidc_client_id || "mv", + base_url: oidc_base_url || "http://localhost:8080/auth/v1", + client_secret: client_secret, + redirect_uri: System.get_env("OIDC_REDIRECT_URI") || default_redirect_uri # Token signing secret from environment variable # This overrides the placeholder value set in prod.exs + # Supports TOKEN_SIGNING_SECRET or TOKEN_SIGNING_SECRET_FILE for Docker secrets. token_signing_secret = - System.get_env("TOKEN_SIGNING_SECRET") || - raise """ - environment variable TOKEN_SIGNING_SECRET is missing. - You can generate one by calling: mix phx.gen.secret - """ + get_env_or_file!.("TOKEN_SIGNING_SECRET", """ + environment variable TOKEN_SIGNING_SECRET (or TOKEN_SIGNING_SECRET_FILE) is missing. + You can generate one by calling: mix phx.gen.secret + """) config :mv, :token_signing_secret, token_signing_secret config :mv, MvWeb.Endpoint, url: [host: host, port: 443, scheme: "https"], http: [ - # Enable IPv6 and bind on all interfaces. - # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. + # Bind on all IPv4 interfaces. + # Use {0, 0, 0, 0, 0, 0, 0, 0} for IPv6, or {127, 0, 0, 1} for localhost only. # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 - # for details about using IPv6 vs IPv4 and loopback vs public addresses. - ip: {0, 0, 0, 0, 0, 0, 0, 0}, + ip: {0, 0, 0, 0}, port: port ], secret_key_base: secret_key_base, diff --git a/config/test.exs b/config/test.exs index bcb55eb..2c4d2ba 100644 --- a/config/test.exs +++ b/config/test.exs @@ -45,3 +45,6 @@ config :mv, :token_signing_secret, "test_secret_key_for_ash_authentication_token config :mv, :session_identifier, :unsafe config :mv, :require_token_presence_for_authentication, false + +# Enable SQL Sandbox for async LiveView tests +config :mv, :sql_sandbox, true diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0bb2840..b4b7a1f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -2,21 +2,32 @@ services: app: image: git.local-it.org/local-it/mitgliederverwaltung:latest container_name: mv-prod-app - # Use host network for local testing to access localhost:8080 (Rauthy) - # In real production, remove this and use external OIDC provider - network_mode: host + ports: + - "4001:4001" environment: - DATABASE_URL: "ecto://postgres:postgres@localhost:5001/mv_prod" - SECRET_KEY_BASE: "${SECRET_KEY_BASE}" - TOKEN_SIGNING_SECRET: "${TOKEN_SIGNING_SECRET}" - PHX_HOST: "${PHX_HOST}" + # Database configuration using separate variables + # Use Docker service name for internal networking + DATABASE_HOST: "db-prod" + DATABASE_PORT: "5432" + DATABASE_USER: "postgres" + DATABASE_NAME: "mv_prod" + DATABASE_PASSWORD_FILE: "/run/secrets/db_password" + # Phoenix secrets via Docker secrets + SECRET_KEY_BASE_FILE: "/run/secrets/secret_key_base" + TOKEN_SIGNING_SECRET_FILE: "/run/secrets/token_signing_secret" + PHX_HOST: "${PHX_HOST:-localhost}" PORT: "4001" PHX_SERVER: "true" - # Rauthy OIDC config - uses localhost because of host network mode + # Rauthy OIDC config - use host.docker.internal to reach host services OIDC_CLIENT_ID: "mv" - OIDC_BASE_URL: "http://localhost:8080/auth/v1" - OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}" + OIDC_BASE_URL: "http://host.docker.internal:8080/auth/v1" + OIDC_CLIENT_SECRET_FILE: "/run/secrets/oidc_client_secret" OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback" + secrets: + - db_password + - secret_key_base + - token_signing_secret + - oidc_client_secret depends_on: - db-prod restart: unless-stopped @@ -26,13 +37,25 @@ services: container_name: mv-prod-db environment: POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres + POSTGRES_PASSWORD_FILE: /run/secrets/db_password POSTGRES_DB: mv_prod + secrets: + - db_password volumes: - postgres_data_prod:/var/lib/postgresql/data ports: - "5001:5432" restart: unless-stopped +secrets: + db_password: + file: ./secrets/db_password.txt + secret_key_base: + file: ./secrets/secret_key_base.txt + token_signing_secret: + file: ./secrets/token_signing_secret.txt + oidc_client_secret: + file: ./secrets/oidc_client_secret.txt + volumes: postgres_data_prod: diff --git a/docs/database-schema-readme.md b/docs/database-schema-readme.md index d548b82..1644f2a 100644 --- a/docs/database-schema-readme.md +++ b/docs/database-schema-readme.md @@ -115,7 +115,6 @@ Member (1) → (N) Properties ### Member Constraints - First name and last name required (min 1 char) - Email unique, validated format (5-254 chars) -- Birth date cannot be in future - Join date cannot be in future - Exit date must be after join date - Phone: `+?[0-9\- ]{6,20}` @@ -169,7 +168,7 @@ Member (1) → (N) Properties ### Weighted Fields - **Weight A (highest):** first_name, last_name - **Weight B:** email, notes -- **Weight C:** birth_date, phone_number, city, street, house_number, postal_code +- **Weight C:** phone_number, city, street, house_number, postal_code - **Weight D (lowest):** join_date, exit_date ### Usage Example @@ -381,7 +380,7 @@ Install "DBML Language" extension to view/edit DBML files with: - tokens (jti, purpose, extra_data) **Personal Data (GDPR):** -- All member fields (name, email, birth_date, address) +- All member fields (name, email, address) - User email - Token subject diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml index 33c0647..b620830 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -122,7 +122,6 @@ Table members { first_name text [not null, note: 'Member first name (min length: 1)'] last_name text [not null, note: 'Member last name (min length: 1)'] email text [not null, unique, note: 'Member email address (5-254 chars, validated)'] - birth_date date [null, note: 'Date of birth (cannot be in future)'] paid boolean [null, note: 'Payment status flag'] phone_number text [null, note: 'Contact phone number (format: +?[0-9\- ]{6,20})'] join_date date [null, note: 'Date when member joined club (cannot be in future)'] @@ -153,7 +152,7 @@ Table members { **Club Member Master Data** Core entity for membership management containing: - - Personal information (name, birth date, email) + - Personal information (name, email) - Contact details (phone, address) - Membership status (join/exit dates, payment status) - Additional notes @@ -183,7 +182,6 @@ Table members { **Validation Rules:** - first_name, last_name: min 1 character - email: 5-254 characters, valid email format - - birth_date: cannot be in future - join_date: cannot be in future - exit_date: must be after join_date (if both present) - phone_number: matches pattern ^\+?[0-9\- ]{6,20}$ diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 51d0749..629987e 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -329,6 +329,11 @@ end --- +**PR #208:** *Show custom fields per default in member overview* 🔧 +- added show_in_overview as attribute to custom fields +- show custom fields in member overview per default +- can be set to false in the settings for the specific custom field + ## Implementation Decisions ### Architecture Patterns @@ -390,6 +395,7 @@ defmodule Mv.Membership.CustomField do attribute :value_type, :atom # :string, :integer, :boolean, :date, :email attribute :immutable, :boolean # Can't change after creation attribute :required, :boolean # All members must have this + attribute :show_in_overview, :boolean # "If true, this custom field will be displayed in the member overview table" end # CustomFieldValue stores values @@ -1321,6 +1327,33 @@ end --- +## Session: Bulk Email Copy Feature (2025-12-02) + +### Feature Summary +Implemented bulk email copy functionality for selected members (#230). Users can select members and copy their email addresses to clipboard. + +**Key Features:** +- Copy button appears only when visible members are selected +- Email format: `First Last ` with semicolon separator (email client compatible) +- Button shows count of visible selected members (respects search/filter) +- CopyToClipboard JavaScript hook with clipboard API + fallback for older browsers +- Bilingual UI (English/German) + +### Key Decisions + +1. **Email Format:** "First Last " with semicolon - standard for all major email clients +2. **Visible Member Count:** Button shows only visible selected members, not total selected (better UX when filtering) +3. **Server→Client:** Used `push_event/3` - server formats data, client handles clipboard + +### Files Changed +- `lib/mv_web/live/member_live/index.ex` - Event handler, helper function +- `lib/mv_web/live/member_live/index.html.heex` - Copy button +- `assets/js/app.js` - CopyToClipboard hook +- `test/mv_web/member_live/index_test.exs` - 9 new tests +- `priv/gettext/de/LC_MESSAGES/default.po` - German translations + +--- + ## Session: User-Member Linking UI Enhancement (2025-01-13) ### Feature Summary @@ -1553,8 +1586,8 @@ This project demonstrates a modern Phoenix application built with: --- -**Document Version:** 1.2 -**Last Updated:** 2025-11-27 +**Document Version:** 1.3 +**Last Updated:** 2025-12-02 **Maintainer:** Development Team **Status:** Living Document (update as project evolves) diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 9a6517d..2f86f5e 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -65,6 +65,7 @@ - ✅ Sorting by basic fields - ✅ User-Member linking (optional 1:1) - ✅ Email synchronization between User and Member +- ✅ **Bulk email copy** - Copy selected members' email addresses to clipboard (Issue #230) **Closed Issues:** - ✅ [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Fuzzy and substring search (closed 2025-11-12) @@ -94,15 +95,18 @@ - ✅ CustomFieldValue type management - ✅ Dynamic custom field value assignment to members - ✅ Union type storage (JSONB) +- ✅ Default field visibility configuration + +**Closed Issues:** +- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S) +- [#197](https://git.local-it.org/local-it/mitgliederverwaltung/issues/197) - Custom Fields: Add option to show custom fields in member overview (M) +- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Remove birthday field from default configuration (S) - Closed 2025-12-02 **Open Issues:** -- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S) [0/3 tasks] - [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks] -- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Don't show birthday field for default configurations (S, Low priority) - [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority) **Missing Features:** -- ❌ Default field visibility configuration - ❌ Field groups/categories - ❌ Conditional fields (show field X if field Y = value) - ❌ Field validation rules (min/max, regex patterns) @@ -183,10 +187,16 @@ **Current State:** - ✅ Basic "paid" boolean field on members +- ✅ **UI Mock-ups for Contribution Types & Settings** (2025-12-02) - ⚠️ No payment tracking **Open Issues:** - [#156](https://git.local-it.org/local-it/mitgliederverwaltung/issues/156) - Set up & document testing environment for vereinfacht.digital (L, Low priority) +- [#226](https://git.local-it.org/local-it/mitgliederverwaltung/issues/226) - Payment/Contribution Mockup Pages (Preview) + +**Mock-Up Pages (Non-Functional Preview):** +- `/contribution_types` - Contribution Types Management +- `/contribution_settings` - Global Contribution Settings **Missing Features:** - ❌ Membership fee configuration diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 749740d..dbc62b2 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -54,6 +54,9 @@ defmodule Mv.Accounts.User do auth_method :client_secret_jwt code_verifier true + # Request email and profile scopes from OIDC provider (required for Authentik, Keycloak, etc.) + authorization_params scope: "openid email profile" + # id_token_signed_response_alg "EdDSA" #-> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87 end @@ -69,7 +72,7 @@ defmodule Mv.Accounts.User do # Default actions for framework/tooling integration: # - :read -> Standard read used across the app and by admin tooling. # - :destroy-> Standard delete used by admin tooling and maintenance tasks. - # + # # NOTE: :create is INTENTIONALLY excluded from defaults! # Using a default :create would bypass email-synchronization logic. # Always use one of these explicit create actions instead: @@ -185,7 +188,9 @@ defmodule Mv.Accounts.User do oidc_user_info = Ash.Changeset.get_argument(changeset, :oidc_user_info) # Get the new email from OIDC user_info - new_email = Map.get(oidc_user_info, "preferred_username") + # Support both "email" (standard OIDC) and "preferred_username" (Rauthy) + new_email = + Map.get(oidc_user_info, "email") || Map.get(oidc_user_info, "preferred_username") changeset |> Ash.Changeset.change_attribute(:oidc_id, oidc_id) @@ -239,8 +244,11 @@ defmodule Mv.Accounts.User do change fn changeset, _ctx -> user_info = Ash.Changeset.get_argument(changeset, :user_info) + # Support both "email" (standard OIDC like Authentik, Keycloak) and "preferred_username" (Rauthy) + email = user_info["email"] || user_info["preferred_username"] + changeset - |> Ash.Changeset.change_attribute(:email, user_info["preferred_username"]) + |> Ash.Changeset.change_attribute(:email, email) |> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"]) end diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index e1cf397..5b7514c 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -14,6 +14,7 @@ defmodule Mv.Membership.CustomField do - `description` - Optional human-readable description - `immutable` - If true, custom field values cannot be changed after creation - `required` - If true, all members must have this custom field (future feature) + - `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted ## Supported Value Types - `:string` - Text data (max 10,000 characters) @@ -59,10 +60,10 @@ defmodule Mv.Membership.CustomField do actions do defaults [:read, :update] - default_accept [:name, :value_type, :description, :immutable, :required] + default_accept [:name, :value_type, :description, :immutable, :required, :show_in_overview] create :create do - accept [:name, :value_type, :description, :immutable, :required] + accept [:name, :value_type, :description, :immutable, :required, :show_in_overview] change Mv.Membership.CustomField.Changes.GenerateSlug validate string_length(:slug, min: 1) end @@ -119,6 +120,12 @@ defmodule Mv.Membership.CustomField do attribute :required, :boolean, default: false, allow_nil?: false + + attribute :show_in_overview, :boolean, + default: true, + allow_nil?: false, + public?: true, + description: "If true, this custom field will be displayed in the member overview table" end relationships do diff --git a/lib/membership/member.ex b/lib/membership/member.ex index da69861..b788dc9 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -24,7 +24,7 @@ defmodule Mv.Membership.Member do - Email format validation (using EctoCommons.EmailValidator) - Phone number format: international format with 6-20 digits - Postal code format: exactly 5 digits (German format) - - Date validations: birth_date and join_date not in future, exit_date after join_date + - Date validations: join_date not in future, exit_date after join_date - Email uniqueness: prevents conflicts with unlinked users ## Full-Text Search @@ -42,6 +42,10 @@ defmodule Mv.Membership.Member do @member_search_limit 10 @default_similarity_threshold 0.2 + # Use constants from Mv.Constants for member fields + # This ensures consistency across the codebase + @member_fields Mv.Constants.member_fields() + postgres do table "members" repo Mv.Repo @@ -58,21 +62,7 @@ defmodule Mv.Membership.Member do # user_id is NOT in accept list to prevent direct foreign key manipulation argument :user, :map, allow_nil?: true - accept [ - :first_name, - :last_name, - :email, - :birth_date, - :paid, - :phone_number, - :join_date, - :exit_date, - :notes, - :city, - :street, - :house_number, - :postal_code - ] + accept @member_fields change manage_relationship(:custom_field_values, type: :create) @@ -105,21 +95,7 @@ defmodule Mv.Membership.Member do # user_id is NOT in accept list to prevent direct foreign key manipulation argument :user, :map, allow_nil?: true - accept [ - :first_name, - :last_name, - :email, - :birth_date, - :paid, - :phone_number, - :join_date, - :exit_date, - :notes, - :city, - :street, - :house_number, - :postal_code - ] + accept @member_fields change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create) @@ -308,11 +284,6 @@ defmodule Mv.Membership.Member do end end - # Birth date not in the future - validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0), - where: [present(:birth_date)], - message: "cannot be in the future" - # Join date not in the future validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0), where: [present(:join_date)], @@ -375,10 +346,6 @@ defmodule Mv.Membership.Member do constraints min_length: 5, max_length: 254 end - attribute :birth_date, :date do - allow_nil? true - end - attribute :paid, :boolean do allow_nil? true end @@ -434,6 +401,70 @@ defmodule Mv.Membership.Member do identity :unique_email, [:email] end + @doc """ + Checks if a member field should be shown in the overview. + + Reads the visibility configuration from Settings resource. If a field is not + configured in settings, it defaults to `true` (visible). + + ## Parameters + - `field` - Atom representing the member field name (e.g., `:email`, `:street`) + + ## Returns + - `true` if the field should be shown in overview (default) + - `false` if the field is configured as hidden in settings + + ## Examples + + iex> Member.show_in_overview?(:email) + true + + iex> Member.show_in_overview?(:street) + true # or false if configured in settings + + """ + @spec show_in_overview?(atom()) :: boolean() + def show_in_overview?(field) when is_atom(field) do + case Mv.Membership.get_settings() do + {:ok, settings} -> + visibility_config = settings.member_field_visibility || %{} + # Normalize map keys to atoms (JSONB may return string keys) + normalized_config = normalize_visibility_config(visibility_config) + + # Get value from normalized config, default to true + Map.get(normalized_config, field, true) + + {:error, _} -> + # If settings can't be loaded, default to visible + true + end + end + + def show_in_overview?(_), do: true + + # Normalizes visibility config map keys from strings to atoms. + # JSONB in PostgreSQL converts atom keys to string keys when storing. + defp normalize_visibility_config(config) when is_map(config) do + Enum.reduce(config, %{}, fn + {key, value}, acc when is_atom(key) -> + Map.put(acc, key, value) + + {key, value}, acc when is_binary(key) -> + try do + atom_key = String.to_existing_atom(key) + Map.put(acc, atom_key, value) + rescue + ArgumentError -> + acc + end + + _, acc -> + acc + end) + end + + defp normalize_visibility_config(_), do: %{} + @doc """ Performs fuzzy search on members using PostgreSQL trigram similarity. diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 7891d2e..f5a708b 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -6,12 +6,14 @@ defmodule Mv.Membership do - `Member` - Club members with personal information and custom field values - `CustomFieldValue` - Dynamic custom field values attached to members - `CustomField` - Schema definitions for custom fields + - `Setting` - Global application settings (singleton) ## Public API The domain exposes these main actions: - Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1` - Custom field value management: `create_custom_field_value/1`, `list_custom_field_values/0`, etc. - Custom field management: `create_custom_field/1`, `list_custom_fields/0`, etc. + - Settings management: `get_settings/0`, `update_settings/2` ## Admin Interface The domain is configured with AshAdmin for management UI. @@ -45,5 +47,114 @@ defmodule Mv.Membership do define :destroy_custom_field, action: :destroy_with_values define :prepare_custom_field_deletion, action: :prepare_deletion, args: [:id] end + + resource Mv.Membership.Setting do + # Note: create action exists but is not exposed via code interface + # It's only used internally as fallback in get_settings/0 + # Settings should be created via seed script + define :update_settings, action: :update + define :update_member_field_visibility, action: :update_member_field_visibility + end + end + + # Singleton pattern: Get the single settings record + @doc """ + Gets the global settings. + + Settings should normally be created via the seed script (`priv/repo/seeds.exs`). + If no settings exist, this function will create them as a fallback using the + `ASSOCIATION_NAME` environment variable or "Club Name" as default. + + ## Returns + + - `{:ok, settings}` - The settings record + - `{:ok, nil}` - No settings exist (should not happen if seeds were run) + - `{:error, error}` - Error reading settings + + ## Examples + + iex> {:ok, settings} = Mv.Membership.get_settings() + iex> settings.club_name + "My Club" + + """ + def get_settings do + # Try to get the first (and only) settings record + case Ash.read_one(Mv.Membership.Setting, domain: __MODULE__) do + {:ok, nil} -> + # No settings exist - create as fallback (should normally be created via seed script) + default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name" + + Mv.Membership.Setting + |> Ash.Changeset.for_create(:create, %{club_name: default_club_name}) + |> Ash.create!(domain: __MODULE__) + |> then(fn settings -> {:ok, settings} end) + + {:ok, settings} -> + {:ok, settings} + + {:error, error} -> + {:error, error} + end + end + + @doc """ + Updates the global settings. + + ## Parameters + + - `settings` - The settings record to update + - `attrs` - A map of attributes to update (e.g., `%{club_name: "New Name"}`) + + ## 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_settings(settings, %{club_name: "New Club"}) + iex> updated.club_name + "New Club" + + """ + def update_settings(settings, attrs) do + settings + |> Ash.Changeset.for_update(:update, attrs) + |> Ash.update(domain: __MODULE__) + end + + @doc """ + Updates the member field visibility configuration. + + This is a specialized action for updating only the member field visibility settings. + It validates that all keys are valid member fields and all values are booleans. + + ## Parameters + + - `settings` - The settings record to update + - `visibility_config` - A map of member field names (strings) to boolean visibility values + (e.g., `%{"street" => false, "house_number" => false}`) + + ## 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_member_field_visibility(settings, %{"street" => false, "house_number" => false}) + iex> updated.member_field_visibility + %{"street" => false, "house_number" => false} + + """ + def update_member_field_visibility(settings, visibility_config) do + settings + |> Ash.Changeset.for_update(:update_member_field_visibility, %{ + member_field_visibility: visibility_config + }) + |> Ash.update(domain: __MODULE__) end end diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex new file mode 100644 index 0000000..52c0328 --- /dev/null +++ b/lib/membership/setting.ex @@ -0,0 +1,138 @@ +defmodule Mv.Membership.Setting do + @moduledoc """ + Ash resource representing global application settings. + + ## Overview + Settings is a singleton resource that stores global configuration for the association, + such as the club name and branding information. There should only ever be one settings + record in the database. + + ## Attributes + - `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`. + + ## Singleton Pattern + This resource uses a singleton pattern - there should only be one settings record. + The resource is designed to be read and updated, but not created or destroyed + through normal CRUD operations. Initial settings should be seeded. + + ## Environment Variable Support + The `club_name` can be set via the `ASSOCIATION_NAME` environment variable. + If set, the environment variable value is used as a fallback when no database + value exists. Database values always take precedence over environment variables. + + ## Examples + + # Get current settings + {:ok, settings} = Mv.Membership.get_settings() + settings.club_name # => "My Club" + + # Update club name + {:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"}) + + # Update member field visibility + {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false}) + """ + use Ash.Resource, + domain: Mv.Membership, + data_layer: AshPostgres.DataLayer + + postgres do + table "settings" + repo Mv.Repo + end + + resource do + description "Global application settings (singleton resource)" + end + + actions do + defaults [:read] + + # Internal create action - not exposed via code interface + # Used only as fallback in get_settings/0 if settings don't exist + # Settings should normally be created via seed script + create :create do + accept [:club_name, :member_field_visibility] + end + + update :update do + primary? true + require_atomic? false + accept [:club_name, :member_field_visibility] + end + + update :update_member_field_visibility do + description "Updates the visibility configuration for member fields in the overview" + require_atomic? false + accept [:member_field_visibility] + end + end + + validations do + validate present(:club_name), on: [:create, :update] + validate string_length(:club_name, min: 1), on: [:create, :update] + + # Validate member_field_visibility map structure and content + validate fn changeset, _context -> + visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility) + + if visibility && is_map(visibility) do + # Validate all values are booleans + invalid_values = + Enum.filter(visibility, fn {_key, value} -> + not is_boolean(value) + end) + + # Validate all keys are valid member fields + valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + + invalid_keys = + Enum.filter(visibility, 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_visibility, + message: "All values in member_field_visibility must be booleans"} + + not Enum.empty?(invalid_keys) -> + {:error, + field: :member_field_visibility, + message: "Invalid member field keys: #{inspect(invalid_keys)}"} + + true -> + :ok + end + else + :ok + end + end, + on: [:create, :update] + end + + attributes do + uuid_primary_key :id + + attribute :club_name, :string, + allow_nil?: false, + public?: true, + description: "The name of the association/club", + constraints: [ + trim?: true, + min_length: 1 + ] + + attribute :member_field_visibility, :map, + allow_nil?: true, + public?: true, + description: + "Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans." + + timestamps() + end +end diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex new file mode 100644 index 0000000..7bfb07b --- /dev/null +++ b/lib/mv/constants.ex @@ -0,0 +1,34 @@ +defmodule Mv.Constants do + @moduledoc """ + Module for defining constants and atoms. + """ + + @member_fields [ + :first_name, + :last_name, + :email, + :paid, + :phone_number, + :join_date, + :exit_date, + :notes, + :city, + :street, + :house_number, + :postal_code + ] + + @custom_field_prefix "custom_field_" + + def member_fields, do: @member_fields + + @doc """ + Returns the prefix used for custom field keys in field visibility maps. + + ## Examples + + iex> Mv.Constants.custom_field_prefix() + "custom_field_" + """ + def custom_field_prefix, do: @custom_field_prefix +end diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 656d3c0..be64655 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -42,7 +42,11 @@ defmodule MvWeb.CoreComponents do attr :id, :string, doc: "the optional id of flash container" attr :flash, :map, default: %{}, doc: "the map of flash messages to display" attr :title, :string, default: nil - attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup" + + attr :kind, :atom, + values: [:info, :error, :success, :warning], + doc: "used for styling and flash lookup" + attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" slot :inner_block, doc: "the optional inner block that renders the flash message" @@ -56,22 +60,26 @@ defmodule MvWeb.CoreComponents do id={@id} phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} role="alert" - class="toast toast-top toast-end z-50" + class="z-50 toast toast-top toast-end" {@rest} >
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" /> <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" /> + <.icon :if={@kind == :success} name="hero-check-circle" class="size-5 shrink-0" /> + <.icon :if={@kind == :warning} name="hero-information-circle" class="size-5 shrink-0" />

{@title}

{msg}

-
@@ -111,6 +119,123 @@ defmodule MvWeb.CoreComponents do end end + @doc """ + Renders a dropdown menu. + + ## Examples + + <.dropdown_menu items={@items} open={@open} phx_target={@myself} /> + """ + attr :id, :string, default: "dropdown-menu" + attr :items, :list, required: true, doc: "List of %{label: string, value: any} maps" + attr :button_label, :string, default: "Dropdown" + attr :icon, :string, default: nil + attr :checkboxes, :boolean, default: false + attr :selected, :map, default: %{} + attr :open, :boolean, default: false, doc: "Whether the dropdown is open" + attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons" + attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events" + + def dropdown_menu(assigns) do + ~H""" +
+ + + +
+ """ + end + @doc """ Renders an input with label and error messages. @@ -180,7 +305,7 @@ defmodule MvWeb.CoreComponents do end) ~H""" -
+
<.error :for={msg <- @errors}>{msg} @@ -202,9 +331,15 @@ defmodule MvWeb.CoreComponents do def input(%{type: "select"} = assigns) do ~H""" -
+