diff --git a/.drone.yml b/.drone.yml index 483a08a..623114f 100644 --- a/.drone.yml +++ b/.drone.yml @@ -53,8 +53,6 @@ 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 @@ -166,7 +164,7 @@ environment: steps: - name: renovate - image: renovate/renovate:41.173 + image: renovate/renovate:41.151 environment: RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_TOKEN: diff --git a/.env.example b/.env.example index 13154f3..7559b0a 100644 --- a/.env.example +++ b/.env.example @@ -8,9 +8,6 @@ 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 9517a21..63ff39e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,3 @@ npm-debug.log .env .elixir_ls/ - -# Docker secrets directory (generated by `just init-secrets`) -/secrets/ diff --git a/.tool-versions b/.tool-versions index 98239f3..60315fc 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ elixir 1.18.3-otp-27 erlang 27.3.4 -just 1.43.1 +just 1.43.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 28b4a37..74df997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,15 +12,8 @@ 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 b835cf4..b28dbdc 100644 --- a/Justfile +++ b/Justfile @@ -1,7 +1,4 @@ set dotenv-load := true -set export := true - -MIX_QUIET := "1" run: install-dependencies start-database migrate-database seed-database mix phx.server @@ -32,7 +29,6 @@ lint: mix format --check-formatted mix compile --warnings-as-errors mix credo - mix gettext.extract --check-up-to-date audit: mix sobelow --config @@ -87,33 +83,4 @@ regen-migrations migration_name commit_hash='': clean: mix clean rm -rf .elixir_ls - 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 + rm -rf _build \ No newline at end of file diff --git a/README.md b/README.md index 090f4e9..6db7980 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 (works with Authentik, Rauthy, Keycloak, etc.) +- ✅ SSO via OIDC (tested with Rauthy) - 🚧 Self-service & online application - 🚧 Accessibility, GDPR, usability improvements - 🚧 Email sending @@ -147,26 +147,7 @@ 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! - -### 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. +Now you can log in to Mila via OIDC! ## ⚙️ Configuration @@ -229,20 +210,13 @@ For testing the production Docker build locally: # Required variables: SECRET_KEY_BASE= TOKEN_SIGNING_SECRET= - DOMAIN=localhost # or PHX_HOST=localhost + PHX_HOST=localhost - # Optional OIDC configuration: + # Optional (have defaults in docker-compose.prod.yml): # OIDC_CLIENT_ID=mv # OIDC_BASE_URL=http://localhost:8080/auth/v1 - # OIDC_CLIENT_SECRET= - # OIDC_REDIRECT_URI is auto-generated as https://{DOMAIN}/auth/user/rauthy/callback - - # 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 + # OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback + # OIDC_CLIENT_SECRET= ``` 3. **Start development environment** (for Rauthy): @@ -276,7 +250,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** — 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. +4. **Use secure secrets management** (environment variables, Docker secrets, vault) 5. **Configure database backups** diff --git a/assets/js/app.js b/assets/js/app.js index 883ca30..e55a06d 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -27,33 +27,6 @@ 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 06a2cd8..c50356c 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -7,75 +7,6 @@ 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 @@ -90,7 +21,12 @@ if System.get_env("PHX_SERVER") do end if config_env() == :prod do - database_url = build_database_url.() + database_url = + System.get_env("DATABASE_URL") || + raise """ + environment variable DATABASE_URL is missing. + For example: ecto://USER:PASS@HOST/DATABASE + """ maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] @@ -105,72 +41,45 @@ 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 = - 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." + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + 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") - # 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" - + # Rauthy OIDC configuration config :mv, :rauthy, - 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 + 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" # 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 = - 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 - """) + System.get_env("TOKEN_SIGNING_SECRET") || + raise """ + environment variable TOKEN_SIGNING_SECRET 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: [ - # Bind on all IPv4 interfaces. - # Use {0, 0, 0, 0, 0, 0, 0, 0} for IPv6, or {127, 0, 0, 1} for localhost only. + # Enable IPv6 and bind on all interfaces. + # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 - ip: {0, 0, 0, 0}, + # for details about using IPv6 vs IPv4 and loopback vs public addresses. + ip: {0, 0, 0, 0, 0, 0, 0, 0}, port: port ], secret_key_base: secret_key_base, diff --git a/config/test.exs b/config/test.exs index 2c4d2ba..bcb55eb 100644 --- a/config/test.exs +++ b/config/test.exs @@ -45,6 +45,3 @@ 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 b4b7a1f..0bb2840 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -2,32 +2,21 @@ services: app: image: git.local-it.org/local-it/mitgliederverwaltung:latest container_name: mv-prod-app - ports: - - "4001:4001" + # Use host network for local testing to access localhost:8080 (Rauthy) + # In real production, remove this and use external OIDC provider + network_mode: host environment: - # 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}" + 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}" PORT: "4001" PHX_SERVER: "true" - # Rauthy OIDC config - use host.docker.internal to reach host services + # Rauthy OIDC config - uses localhost because of host network mode OIDC_CLIENT_ID: "mv" - OIDC_BASE_URL: "http://host.docker.internal:8080/auth/v1" - OIDC_CLIENT_SECRET_FILE: "/run/secrets/oidc_client_secret" + OIDC_BASE_URL: "http://localhost:8080/auth/v1" + OIDC_CLIENT_SECRET: "${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 @@ -37,25 +26,13 @@ services: container_name: mv-prod-db environment: POSTGRES_USER: postgres - POSTGRES_PASSWORD_FILE: /run/secrets/db_password + POSTGRES_PASSWORD: postgres 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 1644f2a..d548b82 100644 --- a/docs/database-schema-readme.md +++ b/docs/database-schema-readme.md @@ -115,6 +115,7 @@ 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}` @@ -168,7 +169,7 @@ Member (1) → (N) Properties ### Weighted Fields - **Weight A (highest):** first_name, last_name - **Weight B:** email, notes -- **Weight C:** phone_number, city, street, house_number, postal_code +- **Weight C:** birth_date, phone_number, city, street, house_number, postal_code - **Weight D (lowest):** join_date, exit_date ### Usage Example @@ -380,7 +381,7 @@ Install "DBML Language" extension to view/edit DBML files with: - tokens (jti, purpose, extra_data) **Personal Data (GDPR):** -- All member fields (name, email, address) +- All member fields (name, email, birth_date, address) - User email - Token subject diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml index b620830..33c0647 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -122,6 +122,7 @@ 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)'] @@ -152,7 +153,7 @@ Table members { **Club Member Master Data** Core entity for membership management containing: - - Personal information (name, email) + - Personal information (name, birth date, email) - Contact details (phone, address) - Membership status (join/exit dates, payment status) - Additional notes @@ -182,6 +183,7 @@ 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 629987e..51d0749 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -329,11 +329,6 @@ 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 @@ -395,7 +390,6 @@ 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 @@ -1327,33 +1321,6 @@ 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 @@ -1586,8 +1553,8 @@ This project demonstrates a modern Phoenix application built with: --- -**Document Version:** 1.3 -**Last Updated:** 2025-12-02 +**Document Version:** 1.2 +**Last Updated:** 2025-11-27 **Maintainer:** Development Team **Status:** Living Document (update as project evolves) diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 2f86f5e..9a6517d 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -65,7 +65,6 @@ - ✅ 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) @@ -95,18 +94,15 @@ - ✅ 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) @@ -187,16 +183,10 @@ **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 dbc62b2..749740d 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -54,9 +54,6 @@ 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 @@ -72,7 +69,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: @@ -188,9 +185,7 @@ defmodule Mv.Accounts.User do oidc_user_info = Ash.Changeset.get_argument(changeset, :oidc_user_info) # Get the new email from OIDC user_info - # Support both "email" (standard OIDC) and "preferred_username" (Rauthy) - new_email = - Map.get(oidc_user_info, "email") || Map.get(oidc_user_info, "preferred_username") + new_email = Map.get(oidc_user_info, "preferred_username") changeset |> Ash.Changeset.change_attribute(:oidc_id, oidc_id) @@ -244,11 +239,8 @@ 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, email) + |> Ash.Changeset.change_attribute(:email, user_info["preferred_username"]) |> 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 5b7514c..e1cf397 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -14,7 +14,6 @@ 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) @@ -60,10 +59,10 @@ defmodule Mv.Membership.CustomField do actions do defaults [:read, :update] - default_accept [:name, :value_type, :description, :immutable, :required, :show_in_overview] + default_accept [:name, :value_type, :description, :immutable, :required] create :create do - accept [:name, :value_type, :description, :immutable, :required, :show_in_overview] + accept [:name, :value_type, :description, :immutable, :required] change Mv.Membership.CustomField.Changes.GenerateSlug validate string_length(:slug, min: 1) end @@ -120,12 +119,6 @@ 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 b788dc9..da69861 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: join_date not in future, exit_date after join_date + - Date validations: birth_date and join_date not in future, exit_date after join_date - Email uniqueness: prevents conflicts with unlinked users ## Full-Text Search @@ -42,10 +42,6 @@ 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 @@ -62,7 +58,21 @@ 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 @member_fields + accept [ + :first_name, + :last_name, + :email, + :birth_date, + :paid, + :phone_number, + :join_date, + :exit_date, + :notes, + :city, + :street, + :house_number, + :postal_code + ] change manage_relationship(:custom_field_values, type: :create) @@ -95,7 +105,21 @@ 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 @member_fields + accept [ + :first_name, + :last_name, + :email, + :birth_date, + :paid, + :phone_number, + :join_date, + :exit_date, + :notes, + :city, + :street, + :house_number, + :postal_code + ] change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create) @@ -284,6 +308,11 @@ 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)], @@ -346,6 +375,10 @@ 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 @@ -401,70 +434,6 @@ 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 f5a708b..7891d2e 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -6,14 +6,12 @@ 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. @@ -47,114 +45,5 @@ 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 deleted file mode 100644 index 52c0328..0000000 --- a/lib/membership/setting.ex +++ /dev/null @@ -1,138 +0,0 @@ -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 deleted file mode 100644 index 7bfb07b..0000000 --- a/lib/mv/constants.ex +++ /dev/null @@ -1,34 +0,0 @@ -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 be64655..656d3c0 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -42,11 +42,7 @@ 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, :success, :warning], - doc: "used for styling and flash lookup" - + attr :kind, :atom, values: [:info, :error], 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" @@ -60,26 +56,22 @@ defmodule MvWeb.CoreComponents do id={@id} phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} role="alert" - class="z-50 toast toast-top toast-end" + class="toast toast-top toast-end z-50" {@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}

-
@@ -119,123 +111,6 @@ 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. @@ -305,7 +180,7 @@ defmodule MvWeb.CoreComponents do end) ~H""" -
+
<.error :for={msg <- @errors}>{msg} @@ -331,15 +202,9 @@ defmodule MvWeb.CoreComponents do def input(%{type: "select"} = assigns) do ~H""" -
+