Compare commits
144 commits
02e5ce4376
...
078b64a6b0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
078b64a6b0 | ||
| 09c75212b2 | |||
| 0cafdbafcd | |||
| 125f9ae77b | |||
| a143c4e243 | |||
| b0c94234a9 | |||
| 780f5f61ea | |||
| ac2ad0a0d5 | |||
| 875c422b7d | |||
| 6d75766dba | |||
| 354029c9cc | |||
| 671e6ce804 | |||
| 386b4c9e65 | |||
| 88c5f3dde0 | |||
| a67a91cffa | |||
| c8968636a8 | |||
| 40835f7a2d | |||
| 13f77b5c0a | |||
| dce2053ce7 | |||
| e81aecce48 | |||
| 397cbde9d6 | |||
| 831149f463 | |||
| 944b868478 | |||
| d10f2ecc90 | |||
| d757d1b9be | |||
| 39d2cb7820 | |||
| ba78a6ac7a | |||
| e2ace3d2a8 | |||
| e803dbdf8b | |||
| f9ff6d3d2d | |||
| dfdf4c980b | |||
| cf354bcf25 | |||
| fdae610da0 | |||
| 37553d8d6c | |||
| 193618eace | |||
| 418b42d35a | |||
| a132383d81 | |||
| b584581114 | |||
| 2284cd93c4 | |||
| 82bd573276 | |||
| e7c4a4f62f | |||
| 100ed96493 | |||
| 11179e51f0 | |||
| 4313703538 | |||
| b509dc4ea3 | |||
| 9fbca13342 | |||
| 3da0ebcb3f | |||
| 4b4ec63613 | |||
| df05eafc99 | |||
| 90ced26a0e | |||
| adc6608e54 | |||
| 9a03485604 | |||
| 078809981d | |||
| 48b0823091 | |||
| af193840e2 | |||
| 52a62bd679 | |||
| 39b285a714 | |||
| 173f522da5 | |||
| 70b3875154 | |||
| 8ba15eb16b | |||
| a32789b90c | |||
| 2af23f4042 | |||
| 21ec86839a | |||
| efb3e1cc37 | |||
| c246ca59db | |||
| edf8b2b79e | |||
| bc75a5853a | |||
| e259c29224 | |||
| 93916a09f9 | |||
| a273b54c75 | |||
| 158ac52d97 | |||
| 7f77eb7023 | |||
| 2b3c94d3b2 | |||
| e9290b7156 | |||
| 8400e727a7 | |||
| 47f18e9ef3 | |||
| 10e5270273 | |||
| 55fb845855 | |||
| 918b02a714 | |||
| d02461f8ea | |||
| 5ce220862f | |||
| 4ba03821a2 | |||
| 527657d37b | |||
| 87e54cb13f | |||
| 293e85334f | |||
| 4f3d0c21a8 | |||
| a19026e430 | |||
| 1084f67f1f | |||
| 87c5db020d | |||
| 7375b83167 | |||
| c416d0fb91 | |||
| 150bba2ef8 | |||
| 6922086fa1 | |||
| 1805916359 | |||
| 8fd981806e | |||
| a4ed2498e7 | |||
| 92e3e50d49 | |||
| 3852655597 | |||
| 7305c63130 | |||
| 56516d78b6 | |||
| 44f88f1ddd | |||
| a69ccf0ff9 | |||
| b8afaff2c2 | |||
| 680ee22482 | |||
| f9ad8fa753 | |||
| fd95f08458 | |||
| 4bfaeb1b6e | |||
| 2a4dbc981c | |||
| d3fd4d6c0e | |||
| 0c75776915 | |||
| 3481b9dadf | |||
| cdc91aec57 | |||
| 0eab45ebfd | |||
| 5e51f99797 | |||
| 5406318e8d | |||
| f6bfeadb7b | |||
| c7c6d329fb | |||
| e920d6b39c | |||
| c1f9750972 | |||
| 8104451d35 | |||
| bb362e1636 | |||
| 41e3a52482 | |||
| b71df98ba2 | |||
| eb42b9fe0a | |||
| 85e1f370f6 | |||
| 9d98ec2494 | |||
| 017ca8b32c | |||
| 3cfae95b1e | |||
| c3502a326e | |||
| d9e48a37d2 | |||
| 687d653fb7 | |||
| 899039b3ee | |||
| 37fcc26b22 | |||
| 1495ef4592 | |||
| 001fca1d16 | |||
| 2693f67d33 | |||
| 7522724945 | |||
| 39afaf3999 | |||
| 5a0a261cd6 | |||
| 91c5e17994 | |||
| b94a4a65d3 | |||
|
|
7882370f4a | ||
| 9a276218c5 | |||
|
|
d8ab0d80db |
156 changed files with 27790 additions and 1400 deletions
|
|
@ -158,11 +158,11 @@
|
|||
{Credo.Check.Warning.UnusedRegexOperation, []},
|
||||
{Credo.Check.Warning.UnusedStringOperation, []},
|
||||
{Credo.Check.Warning.UnusedTupleOperation, []},
|
||||
{Credo.Check.Warning.WrongTestFileExtension, []}
|
||||
{Credo.Check.Warning.WrongTestFileExtension, []},
|
||||
# Module documentation check (enabled after adding @moduledoc to all modules)
|
||||
{Credo.Check.Readability.ModuleDoc, []}
|
||||
],
|
||||
disabled: [
|
||||
# Checks disabled by the Mitgliederverwaltung Team
|
||||
{Credo.Check.Readability.ModuleDoc, []},
|
||||
#
|
||||
# Checks scheduled for next check update (opt-in for now)
|
||||
{Credo.Check.Refactor.UtcNowTruncate, []},
|
||||
|
|
|
|||
53
.drone.yml
53
.drone.yml
|
|
@ -4,7 +4,7 @@ name: check
|
|||
|
||||
services:
|
||||
- name: postgres
|
||||
image: docker.io/library/postgres:17.5
|
||||
image: docker.io/library/postgres:17.6
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
|
@ -53,9 +53,11 @@ 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.5
|
||||
image: docker.io/library/postgres:17.6
|
||||
commands:
|
||||
# Wait for postgres to become available
|
||||
- |
|
||||
|
|
@ -100,6 +102,53 @@ volumes:
|
|||
host:
|
||||
path: /tmp/drone_cache
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: build-and-publish
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
event:
|
||||
- push
|
||||
- tag
|
||||
|
||||
steps:
|
||||
- name: build-and-publish-container
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: git.local-it.org
|
||||
repo: git.local-it.org/local-it/mitgliederverwaltung
|
||||
username:
|
||||
from_secret: DRONE_REGISTRY_USERNAME
|
||||
password:
|
||||
from_secret: DRONE_REGISTRY_TOKEN
|
||||
auto_tag: true
|
||||
auto_tag_suffix: ${DRONE_COMMIT_SHA:0:8}
|
||||
when:
|
||||
event:
|
||||
- tag
|
||||
|
||||
- name: build-and-publish-container-branch
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: git.local-it.org
|
||||
repo: git.local-it.org/local-it/mitgliederverwaltung
|
||||
username:
|
||||
from_secret: DRONE_REGISTRY_USERNAME
|
||||
password:
|
||||
from_secret: DRONE_REGISTRY_TOKEN
|
||||
tags:
|
||||
- latest
|
||||
- ${DRONE_COMMIT_SHA:0:8}
|
||||
when:
|
||||
event:
|
||||
- push
|
||||
|
||||
depends_on:
|
||||
- check
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
|
|
|
|||
20
.env.example
20
.env.example
|
|
@ -1 +1,19 @@
|
|||
OIDC_CLIENT_SECRET=
|
||||
# Production Environment Variables for docker-compose.prod.yml
|
||||
# Copy this file to .env and fill in the actual values
|
||||
|
||||
# Required: Phoenix secrets (generate with: mix phx.gen.secret)
|
||||
SECRET_KEY_BASE=changeme-run-mix-phx.gen.secret
|
||||
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
|
||||
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
||||
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
|
||||
# OIDC_CLIENT_SECRET=your-rauthy-client-secret
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
elixir 1.18.3-otp-27
|
||||
erlang 27.3.4
|
||||
just 1.42.4
|
||||
just 1.43.0
|
||||
|
|
|
|||
25
CHANGELOG.md
Normal file
25
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- User-Member linking with fuzzy search autocomplete (#168)
|
||||
- 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 <email>" 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
|
||||
|
||||
### 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
|
||||
|
||||
2578
CODE_GUIDELINES.md
Normal file
2578
CODE_GUIDELINES.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -14,7 +14,7 @@
|
|||
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
|
||||
FROM ${BUILDER_IMAGE} AS builder
|
||||
|
||||
# install build dependencies
|
||||
RUN apt-get update -y && apt-get install -y build-essential git \
|
||||
|
|
@ -70,9 +70,9 @@ RUN apt-get update -y && \
|
|||
# Set the locale
|
||||
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||
|
||||
ENV LANG en_US.UTF-8
|
||||
ENV LANGUAGE en_US:en
|
||||
ENV LC_ALL en_US.UTF-8
|
||||
ENV LANG=en_US.UTF-8
|
||||
ENV LANGUAGE=en_US:en
|
||||
ENV LC_ALL=en_US.UTF-8
|
||||
|
||||
WORKDIR "/app"
|
||||
RUN chown nobody /app
|
||||
|
|
|
|||
7
Justfile
7
Justfile
|
|
@ -29,6 +29,7 @@ lint:
|
|||
mix format --check-formatted
|
||||
mix compile --warnings-as-errors
|
||||
mix credo
|
||||
mix gettext.extract --check-up-to-date
|
||||
|
||||
audit:
|
||||
mix sobelow --config
|
||||
|
|
@ -84,3 +85,9 @@ 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 '/^<<<<<<< HEAD$/d; /^=======$/d; /^>>>>>>>/d' {} \;
|
||||
104
README.md
104
README.md
|
|
@ -161,34 +161,98 @@ Now you can log in to Mila via OIDC!
|
|||
|
||||
## 🏗️ Architecture
|
||||
|
||||
- **Backend:** Elixir, Phoenix, LiveView, Ash Framework
|
||||
- **Frontend:** Phoenix LiveView + DaisyUI + Heroicons
|
||||
- **Database:** PostgreSQL (via AshPostgres)
|
||||
- **Auth:** AshAuthentication (OIDC + password strategy)
|
||||
- **Mail:** Swoosh
|
||||
- **i18n:** Gettext
|
||||
**Tech Stack Overview:**
|
||||
- **Backend:** Elixir + Phoenix + Ash Framework
|
||||
- **Frontend:** Phoenix LiveView + Tailwind CSS + DaisyUI
|
||||
- **Database:** PostgreSQL
|
||||
- **Auth:** AshAuthentication (OIDC + password)
|
||||
|
||||
Code structure:
|
||||
- `lib/mv/` — core Ash resources/domains (`Accounts`, `Membership`)
|
||||
**Code Structure:**
|
||||
- `lib/accounts/` & `lib/membership/` — Ash resources and domains
|
||||
- `lib/mv_web/` — Phoenix controllers, LiveViews, components
|
||||
- `assets/` — frontend assets (Tailwind, JS, etc.)
|
||||
- `assets/` — Tailwind, JavaScript, static files
|
||||
|
||||
📚 **Full tech stack details:** See [`CODE_GUIDELINES.md`](CODE_GUIDELINES.md)
|
||||
📖 **Implementation history:** See [`docs/development-progress-log.md`](docs/development-progress-log.md)
|
||||
🗄️ **Database schema:** See [`docs/database-schema-readme.md`](docs/database-schema-readme.md)
|
||||
|
||||
## 🧑💻 Development
|
||||
|
||||
Useful `just` commands:
|
||||
- `just run` — start DB, Mailcrab, Rauthy, app
|
||||
- `just test` — run tests
|
||||
- `just lint` — run code style checks (credo, formatter)
|
||||
- `just audit` — run security audits
|
||||
- `just reset-database` — reset local DB
|
||||
- `just regen-migrations <name>` — regenerate migrations
|
||||
**Common commands:**
|
||||
```bash
|
||||
just run # Start full dev environment
|
||||
just test # Run test suite
|
||||
just lint # Code style checks
|
||||
just audit # Security audits
|
||||
just reset-database # Reset local DB
|
||||
```
|
||||
|
||||
## 📦 Deployment
|
||||
📚 **Full development guidelines:** See [`CODE_GUIDELINES.md`](CODE_GUIDELINES.md)
|
||||
|
||||
A production release image is built via the provided `Dockerfile`.
|
||||
Entrypoint: `/app/bin/server`.
|
||||
## 📦 Production Deployment
|
||||
|
||||
### Local Production Testing
|
||||
|
||||
For testing the production Docker build locally:
|
||||
|
||||
1. **Generate secrets:**
|
||||
```bash
|
||||
mix phx.gen.secret # for SECRET_KEY_BASE
|
||||
mix phx.gen.secret # for TOKEN_SIGNING_SECRET
|
||||
```
|
||||
|
||||
2. **Create `.env` file:**
|
||||
```bash
|
||||
# Copy template and edit
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
|
||||
# Required variables:
|
||||
SECRET_KEY_BASE=<your-generated-secret>
|
||||
TOKEN_SIGNING_SECRET=<your-generated-secret>
|
||||
PHX_HOST=localhost
|
||||
|
||||
# Optional (have defaults in docker-compose.prod.yml):
|
||||
# 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=<from-rauthy-client>
|
||||
```
|
||||
|
||||
3. **Start development environment** (for Rauthy):
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
4. **Start production environment:**
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up
|
||||
```
|
||||
|
||||
5. **Run database migrations:**
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml exec app /app/bin/mv eval "Mv.Release.migrate"
|
||||
```
|
||||
|
||||
6. **Access the production app:**
|
||||
- Production App: http://localhost:4001
|
||||
- Uses same Rauthy instance as dev (localhost:8080)
|
||||
|
||||
**Note:** The local production setup uses `network_mode: host` to share localhost with the development Rauthy instance. For real production deployment, configure an external OIDC provider and remove `network_mode: host`.
|
||||
|
||||
### Real Production Deployment
|
||||
|
||||
For actual production deployment:
|
||||
|
||||
1. **Use an external OIDC provider** (not the local Rauthy)
|
||||
2. **Update `docker-compose.prod.yml`:**
|
||||
- Remove `network_mode: host`
|
||||
- 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)
|
||||
5. **Configure database backups**
|
||||
|
||||
More detailed deployment docs are planned.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
|
|
|
|||
|
|
@ -23,9 +23,69 @@ import {LiveSocket} from "phoenix_live_view"
|
|||
import topbar from "../vendor/topbar"
|
||||
|
||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
|
||||
// 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() {
|
||||
this.handleKeyDown = (e) => {
|
||||
const isDropdownOpen = this.el.getAttribute("aria-expanded") === "true"
|
||||
|
||||
if (e.key === "Enter" && isDropdownOpen) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
this.el.addEventListener("keydown", this.handleKeyDown)
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.el.removeEventListener("keydown", this.handleKeyDown)
|
||||
}
|
||||
}
|
||||
|
||||
let liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: {_csrf_token: csrfToken}
|
||||
params: {_csrf_token: csrfToken},
|
||||
hooks: Hooks
|
||||
})
|
||||
|
||||
// Listen for custom events from LiveView
|
||||
window.addEventListener("phx:set-input-value", (e) => {
|
||||
const {id, value} = e.detail
|
||||
const input = document.getElementById(id)
|
||||
if (input) {
|
||||
input.value = value
|
||||
}
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
|
|
|
|||
|
|
@ -16,5 +16,16 @@ config :swoosh, local: false
|
|||
# Do not print debug messages in production
|
||||
config :logger, level: :info
|
||||
|
||||
# AshAuthentication production configuration
|
||||
# These must be set at compile-time (not in runtime.exs) because
|
||||
# Application.compile_env!/3 is used in lib/accounts/user.ex
|
||||
config :mv, :session_identifier, :jti
|
||||
|
||||
config :mv, :require_token_presence_for_authentication, true
|
||||
|
||||
# Token signing secret - using a placeholder that MUST be overridden
|
||||
# at runtime via environment variable in config/runtime.exs
|
||||
config :mv, :token_signing_secret, "REPLACE_ME_AT_RUNTIME"
|
||||
|
||||
# Runtime production configuration, including reading
|
||||
# of environment variables, is done on config/runtime.exs.
|
||||
|
|
|
|||
|
|
@ -53,12 +53,24 @@ if config_env() == :prod do
|
|||
|
||||
config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
|
||||
|
||||
config :mv, :rauthy, redirect_uri: "http://localhost:4000/auth/user/rauthy/callback"
|
||||
# Rauthy OIDC configuration
|
||||
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"
|
||||
|
||||
# AshAuthentication production configuration
|
||||
config :mv, :session_identifier, :jti
|
||||
# Token signing secret from environment variable
|
||||
# This overrides the placeholder value set in prod.exs
|
||||
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
|
||||
"""
|
||||
|
||||
config :mv, :require_token_presence_for_authentication, true
|
||||
config :mv, :token_signing_secret, token_signing_secret
|
||||
|
||||
config :mv, MvWeb.Endpoint,
|
||||
url: [host: host, port: 443, scheme: "https"],
|
||||
|
|
@ -70,7 +82,13 @@ if config_env() == :prod do
|
|||
ip: {0, 0, 0, 0, 0, 0, 0, 0},
|
||||
port: port
|
||||
],
|
||||
secret_key_base: secret_key_base
|
||||
secret_key_base: secret_key_base,
|
||||
# Allow connections from localhost and 127.0.0.1
|
||||
check_origin: [
|
||||
"//#{host}",
|
||||
"//localhost:#{port}",
|
||||
"//127.0.0.1:#{port}"
|
||||
]
|
||||
|
||||
# ## SSL Support
|
||||
#
|
||||
|
|
|
|||
38
docker-compose.prod.yml
Normal file
38
docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
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
|
||||
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}"
|
||||
PORT: "4001"
|
||||
PHX_SERVER: "true"
|
||||
# Rauthy OIDC config - uses localhost because of host network mode
|
||||
OIDC_CLIENT_ID: "mv"
|
||||
OIDC_BASE_URL: "http://localhost:8080/auth/v1"
|
||||
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}"
|
||||
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback"
|
||||
depends_on:
|
||||
- db-prod
|
||||
restart: unless-stopped
|
||||
|
||||
db-prod:
|
||||
image: postgres:16-alpine
|
||||
container_name: mv-prod-db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: mv_prod
|
||||
volumes:
|
||||
- postgres_data_prod:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5001:5432"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data_prod:
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
networks:
|
||||
local:
|
||||
rauthy-dev:
|
||||
driver: bridge
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:17.5-alpine
|
||||
image: postgres:17.6-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
|
@ -39,12 +38,8 @@ services:
|
|||
- LISTEN_SCHEME=http
|
||||
- PUB_URL=localhost:8080
|
||||
- BOOTSTRAP_ADMIN_PASSWORD_PLAIN=RauthyTest12345
|
||||
#- HIQLITE=false
|
||||
#- PG_HOST=db
|
||||
#- PG_PORT=5432
|
||||
#- PG_USER=postgres
|
||||
#- PG_PASSWORD=postgres
|
||||
#- PG_DB_NAME=mv_dev
|
||||
# Disable strict IP validation to allow access from multiple Docker networks
|
||||
- SESSION_VALIDATE_IP=false
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
|
|
|
|||
463
docs/database-schema-readme.md
Normal file
463
docs/database-schema-readme.md
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
# Database Schema Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides a comprehensive overview of the Mila Membership Management System database schema.
|
||||
|
||||
## Quick Links
|
||||
|
||||
- **DBML File:** [`database_schema.dbml`](./database_schema.dbml)
|
||||
- **Visualize Online:**
|
||||
- [dbdiagram.io](https://dbdiagram.io) - Upload the DBML file
|
||||
- [dbdocs.io](https://dbdocs.io) - Generate interactive documentation
|
||||
|
||||
## Schema Statistics
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| **Tables** | 5 |
|
||||
| **Domains** | 2 (Accounts, Membership) |
|
||||
| **Relationships** | 3 |
|
||||
| **Indexes** | 15+ |
|
||||
| **Triggers** | 1 (Full-text search) |
|
||||
|
||||
## Tables Overview
|
||||
|
||||
### Accounts Domain
|
||||
|
||||
#### `users`
|
||||
- **Purpose:** User authentication and session management
|
||||
- **Rows (Estimated):** Low to Medium (typically 10-50% of members)
|
||||
- **Key Features:**
|
||||
- Dual authentication (Password + OIDC)
|
||||
- Optional 1:1 link to members
|
||||
- Email as source of truth when linked
|
||||
|
||||
#### `tokens`
|
||||
- **Purpose:** JWT token storage for AshAuthentication
|
||||
- **Rows (Estimated):** Medium to High (multiple tokens per user)
|
||||
- **Key Features:**
|
||||
- Token lifecycle management
|
||||
- Revocation support
|
||||
- Multiple token purposes
|
||||
|
||||
### Membership Domain
|
||||
|
||||
#### `members`
|
||||
- **Purpose:** Club member master data
|
||||
- **Rows (Estimated):** High (core entity)
|
||||
- **Key Features:**
|
||||
- Complete member profile
|
||||
- Full-text search via tsvector
|
||||
- Bidirectional email sync with users
|
||||
- Flexible address and contact data
|
||||
|
||||
#### `custom_field_values`
|
||||
- **Purpose:** Dynamic custom member attributes
|
||||
- **Rows (Estimated):** Variable (N per member)
|
||||
- **Key Features:**
|
||||
- Union type value storage (JSONB)
|
||||
- Multiple data types supported
|
||||
- One custom field value per custom field per member
|
||||
|
||||
#### `custom_fields`
|
||||
- **Purpose:** Schema definitions for custom_field_values
|
||||
- **Rows (Estimated):** Low (admin-defined)
|
||||
- **Key Features:**
|
||||
- Type definitions
|
||||
- Immutable and required flags
|
||||
- Centralized custom field management
|
||||
|
||||
## Key Relationships
|
||||
|
||||
```
|
||||
User (0..1) ←→ (0..1) Member
|
||||
↓
|
||||
Tokens (N)
|
||||
|
||||
Member (1) → (N) Properties
|
||||
↓
|
||||
CustomField (1)
|
||||
```
|
||||
|
||||
### Relationship Details
|
||||
|
||||
1. **User ↔ Member (Optional 1:1, both sides optional)**
|
||||
- A User can have 0 or 1 Member (`user.member_id` can be NULL)
|
||||
- A Member can have 0 or 1 User (optional `has_one` relationship)
|
||||
- Both entities can exist independently
|
||||
- Email synchronization when linked (User.email is source of truth)
|
||||
- `ON DELETE SET NULL` on user side (User preserved when Member deleted)
|
||||
|
||||
2. **Member → Properties (1:N)**
|
||||
- One member, many custom_field_values
|
||||
- `ON DELETE CASCADE` - custom_field_values deleted with member
|
||||
- Composite unique constraint (member_id, custom_field_id)
|
||||
|
||||
3. **CustomFieldValue → CustomField (N:1)**
|
||||
- Properties reference type definition
|
||||
- `ON DELETE RESTRICT` - cannot delete type if in use
|
||||
- Type defines data structure
|
||||
|
||||
## Important Business Rules
|
||||
|
||||
### Email Synchronization
|
||||
- **User.email** is the source of truth when linked
|
||||
- On linking: Member.email ← User.email (overwrite)
|
||||
- After linking: Changes sync bidirectionally
|
||||
- Validation prevents email conflicts
|
||||
|
||||
### Authentication Strategies
|
||||
- **Password:** Email + hashed_password
|
||||
- **OIDC:** Email + oidc_id (Rauthy provider)
|
||||
- At least one method required per user
|
||||
|
||||
### Member Constraints
|
||||
- First name and last name required (min 1 char)
|
||||
- Email unique, validated format (5-254 chars)
|
||||
- Join date cannot be in future
|
||||
- Exit date must be after join date
|
||||
- Phone: `+?[0-9\- ]{6,20}`
|
||||
- Postal code: 5 digits
|
||||
|
||||
### CustomFieldValue System
|
||||
- Maximum one custom field value per custom field per member
|
||||
- Value stored as union type in JSONB
|
||||
- Supported types: string, integer, boolean, date, email
|
||||
- Types can be marked as immutable or required
|
||||
|
||||
## Indexes
|
||||
|
||||
### Performance Indexes
|
||||
|
||||
**members:**
|
||||
- `search_vector` (GIN) - Full-text search (tsvector)
|
||||
- `first_name` (GIN trgm) - Fuzzy search on first name
|
||||
- `last_name` (GIN trgm) - Fuzzy search on last name
|
||||
- `email` (GIN trgm) - Fuzzy search on email
|
||||
- `city` (GIN trgm) - Fuzzy search on city
|
||||
- `street` (GIN trgm) - Fuzzy search on street
|
||||
- `notes` (GIN trgm) - Fuzzy search on notes
|
||||
- `email` (B-tree) - Exact email lookups
|
||||
- `last_name` (B-tree) - Name sorting
|
||||
- `join_date` (B-tree) - Date filtering
|
||||
- `paid` (partial B-tree) - Payment status queries
|
||||
|
||||
**custom_field_values:**
|
||||
- `member_id` - Member custom field value lookups
|
||||
- `custom_field_id` - Type-based queries
|
||||
- Composite `(member_id, custom_field_id)` - Uniqueness
|
||||
|
||||
**tokens:**
|
||||
- `subject` - User token lookups
|
||||
- `expires_at` - Token cleanup
|
||||
- `purpose` - Purpose-based queries
|
||||
|
||||
**users:**
|
||||
- `email` (unique) - Login lookups
|
||||
- `oidc_id` (unique) - OIDC authentication
|
||||
- `member_id` (unique) - Member linkage
|
||||
|
||||
## Full-Text Search
|
||||
|
||||
### Implementation
|
||||
- **Trigger:** `members_search_vector_trigger()`
|
||||
- **Function:** Automatically updates `search_vector` on INSERT/UPDATE
|
||||
- **Index Type:** GIN (Generalized Inverted Index)
|
||||
|
||||
### Weighted Fields
|
||||
- **Weight A (highest):** first_name, last_name
|
||||
- **Weight B:** email, notes
|
||||
- **Weight C:** phone_number, city, street, house_number, postal_code
|
||||
- **Weight D (lowest):** join_date, exit_date
|
||||
|
||||
### Usage Example
|
||||
```sql
|
||||
SELECT * FROM members
|
||||
WHERE search_vector @@ to_tsquery('simple', 'john & doe');
|
||||
```
|
||||
|
||||
## Fuzzy Search (Trigram-based)
|
||||
|
||||
### Implementation
|
||||
- **Extension:** `pg_trgm` (PostgreSQL Trigram)
|
||||
- **Index Type:** GIN with `gin_trgm_ops` operator class
|
||||
- **Similarity Threshold:** 0.2 (default, configurable)
|
||||
- **Added:** November 2025 (PR #187, closes #162)
|
||||
|
||||
### How It Works
|
||||
Fuzzy search combines multiple search strategies:
|
||||
1. **Full-text search** - Primary filter using tsvector
|
||||
2. **Trigram similarity** - `similarity(field, query) > threshold`
|
||||
3. **Word similarity** - `word_similarity(query, field) > threshold`
|
||||
4. **Substring matching** - `LIKE` and `ILIKE` for exact substrings
|
||||
5. **Modulo operator** - `query % field` for quick similarity check
|
||||
|
||||
### Indexed Fields for Fuzzy Search
|
||||
- `first_name` - GIN trigram index
|
||||
- `last_name` - GIN trigram index
|
||||
- `email` - GIN trigram index
|
||||
- `city` - GIN trigram index
|
||||
- `street` - GIN trigram index
|
||||
- `notes` - GIN trigram index
|
||||
|
||||
### Usage Example (Ash Action)
|
||||
```elixir
|
||||
# In LiveView or context
|
||||
Member.fuzzy_search(Member, query: "john", similarity_threshold: 0.2)
|
||||
|
||||
# Or using Ash Query directly
|
||||
Member
|
||||
|> Ash.Query.for_read(:search, %{query: "john", similarity_threshold: 0.2})
|
||||
|> Mv.Membership.read!()
|
||||
```
|
||||
|
||||
### Usage Example (SQL)
|
||||
```sql
|
||||
-- Trigram similarity search
|
||||
SELECT * FROM members
|
||||
WHERE similarity(first_name, 'john') > 0.2
|
||||
OR similarity(last_name, 'doe') > 0.2
|
||||
ORDER BY similarity(first_name, 'john') DESC;
|
||||
|
||||
-- Word similarity (better for partial matches)
|
||||
SELECT * FROM members
|
||||
WHERE word_similarity('john', first_name) > 0.2;
|
||||
|
||||
-- Quick similarity check with % operator
|
||||
SELECT * FROM members
|
||||
WHERE 'john' % first_name;
|
||||
```
|
||||
|
||||
### Performance Considerations
|
||||
- **GIN indexes** speed up trigram operations significantly
|
||||
- **Similarity threshold** of 0.2 balances precision and recall
|
||||
- **Combined approach** (FTS + trigram) provides best results
|
||||
- Lower threshold = more results but less specific
|
||||
|
||||
## Database Extensions
|
||||
|
||||
### Required PostgreSQL Extensions
|
||||
|
||||
1. **uuid-ossp**
|
||||
- Purpose: UUID generation functions
|
||||
- Used for: `gen_random_uuid()`, `uuid_generate_v7()`
|
||||
|
||||
2. **citext**
|
||||
- Purpose: Case-insensitive text type
|
||||
- Used for: `users.email` (case-insensitive email matching)
|
||||
|
||||
3. **pg_trgm**
|
||||
- Purpose: Trigram-based fuzzy text search and similarity matching
|
||||
- Used for: Fuzzy member search with similarity scoring
|
||||
- Operators: `%` (similarity), `word_similarity()`, `similarity()`
|
||||
- Added in: Migration `20251001141005_add_trigram_to_members.exs`
|
||||
|
||||
### Installation
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "citext";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Ash Migrations
|
||||
This project uses Ash Framework's migration system:
|
||||
|
||||
```bash
|
||||
# Generate new migration
|
||||
mix ash.codegen --name add_new_feature
|
||||
|
||||
# Apply migrations
|
||||
mix ash.setup
|
||||
|
||||
# Rollback migrations
|
||||
mix ash_postgres.rollback -n 1
|
||||
```
|
||||
|
||||
### Migration Files Location
|
||||
```
|
||||
priv/repo/migrations/
|
||||
├── 20250421101957_initialize_extensions_1.exs
|
||||
├── 20250528163901_initial_migration.exs
|
||||
├── 20250617090641_member_fields.exs
|
||||
├── 20250620110850_add_accounts_domain.exs
|
||||
├── 20250912085235_AddSearchVectorToMembers.exs
|
||||
├── 20250926180341_add_unique_email_to_members.exs
|
||||
├── 20251001141005_add_trigram_to_members.exs
|
||||
└── 20251016130855_add_constraints_for_user_member_and_property.exs
|
||||
```
|
||||
|
||||
## Data Integrity
|
||||
|
||||
### Foreign Key Behaviors
|
||||
|
||||
| Relationship | On Delete | Rationale |
|
||||
|--------------|-----------|-----------|
|
||||
| `users.member_id → members.id` | SET NULL | Preserve user account when member deleted |
|
||||
| `custom_field_values.member_id → members.id` | CASCADE | Delete custom_field_values with member |
|
||||
| `custom_field_values.custom_field_id → custom_fields.id` | RESTRICT | Prevent deletion of types in use |
|
||||
|
||||
### Validation Layers
|
||||
|
||||
1. **Database Level:**
|
||||
- CHECK constraints
|
||||
- NOT NULL constraints
|
||||
- UNIQUE indexes
|
||||
- Foreign key constraints
|
||||
|
||||
2. **Application Level (Ash):**
|
||||
- Custom validators
|
||||
- Email format validation (EctoCommons.EmailValidator)
|
||||
- Business rule validation
|
||||
- Cross-entity validation
|
||||
|
||||
3. **UI Level:**
|
||||
- Client-side form validation
|
||||
- Real-time feedback
|
||||
- Error messages
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Query Patterns
|
||||
|
||||
**High Frequency:**
|
||||
- Member search (uses GIN index on search_vector)
|
||||
- Member list with filters (uses indexes on join_date, paid)
|
||||
- User authentication (uses unique index on email/oidc_id)
|
||||
- CustomFieldValue lookups by member (uses index on member_id)
|
||||
|
||||
**Medium Frequency:**
|
||||
- Member CRUD operations
|
||||
- CustomFieldValue updates
|
||||
- Token validation
|
||||
|
||||
**Low Frequency:**
|
||||
- CustomField management
|
||||
- User-Member linking
|
||||
- Bulk operations
|
||||
|
||||
### Optimization Tips
|
||||
|
||||
1. **Use indexes:** All critical query paths have indexes
|
||||
2. **Preload relationships:** Use Ash's `load` to avoid N+1
|
||||
3. **Pagination:** Use keyset pagination (configured by default)
|
||||
4. **Partial indexes:** `members.paid` index only non-NULL values
|
||||
5. **Search optimization:** Full-text search via tsvector, not LIKE
|
||||
|
||||
## Visualization
|
||||
|
||||
### Using dbdiagram.io
|
||||
|
||||
1. Visit [https://dbdiagram.io](https://dbdiagram.io)
|
||||
2. Click "Import" → "From file"
|
||||
3. Upload `database_schema.dbml`
|
||||
4. View interactive diagram with relationships
|
||||
|
||||
### Using dbdocs.io
|
||||
|
||||
1. Install dbdocs CLI: `npm install -g dbdocs`
|
||||
2. Generate docs: `dbdocs build database_schema.dbml`
|
||||
3. View generated documentation
|
||||
|
||||
### VS Code Extension
|
||||
|
||||
Install "DBML Language" extension to view/edit DBML files with:
|
||||
- Syntax highlighting
|
||||
- Inline documentation
|
||||
- Error checking
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Sensitive Data
|
||||
|
||||
**Encrypted:**
|
||||
- `users.hashed_password` (bcrypt)
|
||||
|
||||
**Should Not Log:**
|
||||
- hashed_password
|
||||
- tokens (jti, purpose, extra_data)
|
||||
|
||||
**Personal Data (GDPR):**
|
||||
- All member fields (name, email, address)
|
||||
- User email
|
||||
- Token subject
|
||||
|
||||
### Access Control
|
||||
|
||||
- Implement through Ash policies
|
||||
- Row-level security considerations for future
|
||||
- Audit logging for sensitive operations
|
||||
|
||||
## Backup Recommendations
|
||||
|
||||
### Critical Tables (Priority 1)
|
||||
- `members` - Core business data
|
||||
- `users` - Authentication data
|
||||
- `custom_fields` - Schema definitions
|
||||
|
||||
### Important Tables (Priority 2)
|
||||
- `custom_field_values` - Member custom data
|
||||
- `tokens` - Can be regenerated but good to backup
|
||||
|
||||
### Backup Strategy
|
||||
```bash
|
||||
# Full database backup
|
||||
pg_dump -Fc mv_prod > backup_$(date +%Y%m%d).dump
|
||||
|
||||
# Restore
|
||||
pg_restore -d mv_prod backup_20251110.dump
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Database
|
||||
- Separate test database: `mv_test`
|
||||
- Sandbox mode via Ecto.Adapters.SQL.Sandbox
|
||||
- Reset between tests
|
||||
|
||||
### Seed Data
|
||||
```bash
|
||||
# Load seed data
|
||||
mix run priv/repo/seeds.exs
|
||||
```
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Potential Additions
|
||||
|
||||
1. **Audit Log Table**
|
||||
- Track changes to members
|
||||
- Compliance and history tracking
|
||||
|
||||
2. **Payment Tracking**
|
||||
- Payment history table
|
||||
- Transaction records
|
||||
- Fee calculation
|
||||
|
||||
3. **Document Storage**
|
||||
- Member documents/attachments
|
||||
- File metadata table
|
||||
|
||||
4. **Email Queue**
|
||||
- Outbound email tracking
|
||||
- Delivery status
|
||||
|
||||
5. **Roles & Permissions**
|
||||
- User roles (admin, treasurer, member)
|
||||
- Permission management
|
||||
|
||||
## Resources
|
||||
|
||||
- **Ash Framework:** [https://hexdocs.pm/ash](https://hexdocs.pm/ash)
|
||||
- **AshPostgres:** [https://hexdocs.pm/ash_postgres](https://hexdocs.pm/ash_postgres)
|
||||
- **DBML Specification:** [https://dbml.dbdiagram.io](https://dbml.dbdiagram.io)
|
||||
- **PostgreSQL Docs:** [https://www.postgresql.org/docs/](https://www.postgresql.org/docs/)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-11-13
|
||||
**Schema Version:** 1.1
|
||||
**Database:** PostgreSQL 17.6 (dev) / 16 (prod)
|
||||
|
||||
359
docs/database_schema.dbml
Normal file
359
docs/database_schema.dbml
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
// Mila - Membership Management System
|
||||
// Database Schema Documentation
|
||||
//
|
||||
// This file can be used with:
|
||||
// - https://dbdiagram.io
|
||||
// - https://dbdocs.io
|
||||
// - VS Code Extensions: "DBML Language" or "dbdiagram.io"
|
||||
//
|
||||
// Version: 1.2
|
||||
// Last Updated: 2025-11-13
|
||||
|
||||
Project mila_membership_management {
|
||||
database_type: 'PostgreSQL'
|
||||
Note: '''
|
||||
# Mila Membership Management System
|
||||
|
||||
A membership management application for small to mid-sized clubs.
|
||||
|
||||
## Key Features:
|
||||
- User authentication (OIDC + Password with secure account linking)
|
||||
- Member management with flexible custom fields
|
||||
- Bidirectional email synchronization between users and members
|
||||
- Full-text search capabilities (tsvector)
|
||||
- Fuzzy search with trigram matching (pg_trgm)
|
||||
- GDPR-compliant data management
|
||||
|
||||
## Domains:
|
||||
- **Accounts**: User authentication and session management
|
||||
- **Membership**: Club member data and custom fields
|
||||
|
||||
## Required PostgreSQL Extensions:
|
||||
- uuid-ossp (UUID generation)
|
||||
- citext (case-insensitive text)
|
||||
- pg_trgm (trigram-based fuzzy search)
|
||||
'''
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ACCOUNTS DOMAIN
|
||||
// ============================================
|
||||
|
||||
Table users {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
|
||||
email citext [not null, unique, note: 'Email address (case-insensitive) - source of truth when linked to member']
|
||||
hashed_password text [null, note: 'Bcrypt-hashed password (null for OIDC-only users)']
|
||||
oidc_id text [null, unique, note: 'External OIDC identifier from authentication provider (e.g., Rauthy)']
|
||||
member_id uuid [null, unique, note: 'Optional 1:1 link to member record']
|
||||
|
||||
indexes {
|
||||
email [unique, name: 'users_unique_email_index']
|
||||
oidc_id [unique, name: 'users_unique_oidc_id_index']
|
||||
member_id [unique, name: 'users_unique_member_index']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**User Authentication Table**
|
||||
|
||||
Handles user login accounts with two authentication strategies:
|
||||
1. Password-based authentication (email + hashed_password)
|
||||
2. OIDC/SSO authentication (email + oidc_id)
|
||||
|
||||
**Relationship with Members:**
|
||||
- Optional 1:1 relationship with members table (0..1 ↔ 0..1)
|
||||
- A user can have 0 or 1 member (user.member_id can be NULL)
|
||||
- A member can have 0 or 1 user (optional has_one relationship)
|
||||
- Both entities can exist independently
|
||||
- When linked, user.email is the source of truth
|
||||
- Email changes sync bidirectionally between user ↔ member
|
||||
|
||||
**Constraints:**
|
||||
- At least one auth method required (password OR oidc_id)
|
||||
- Email must be unique across all users
|
||||
- OIDC ID must be unique if present
|
||||
- Member can only be linked to one user (enforced by unique index)
|
||||
|
||||
**Deletion Behavior:**
|
||||
- When member is deleted → user.member_id set to NULL (user preserved)
|
||||
- When user is deleted → member.user relationship cleared (member preserved)
|
||||
'''
|
||||
}
|
||||
|
||||
Table tokens {
|
||||
jti text [pk, not null, note: 'JWT ID - unique token identifier']
|
||||
subject text [not null, note: 'Token subject (usually user ID)']
|
||||
purpose text [not null, note: 'Token purpose (e.g., "access", "refresh", "password_reset")']
|
||||
expires_at timestamp [not null, note: 'Token expiration timestamp (UTC)']
|
||||
extra_data jsonb [null, note: 'Additional token metadata']
|
||||
created_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)']
|
||||
updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)']
|
||||
|
||||
indexes {
|
||||
subject [name: 'tokens_subject_idx', note: 'For user token lookups']
|
||||
expires_at [name: 'tokens_expires_at_idx', note: 'For token cleanup queries']
|
||||
purpose [name: 'tokens_purpose_idx', note: 'For purpose-based queries']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**AshAuthentication Token Management**
|
||||
|
||||
Stores JWT tokens for authentication and authorization.
|
||||
|
||||
**Token Purposes:**
|
||||
- `access`: Short-lived access tokens for API requests
|
||||
- `refresh`: Long-lived tokens for obtaining new access tokens
|
||||
- `password_reset`: Temporary tokens for password reset flow
|
||||
- `email_confirmation`: Temporary tokens for email verification
|
||||
|
||||
**Token Lifecycle:**
|
||||
- Tokens are created during login/registration
|
||||
- Can be revoked by deleting the record
|
||||
- Expired tokens should be cleaned up periodically
|
||||
- `store_all_tokens? true` enables token tracking
|
||||
'''
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MEMBERSHIP DOMAIN
|
||||
// ============================================
|
||||
|
||||
Table members {
|
||||
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key (sortable by creation time)']
|
||||
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)']
|
||||
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)']
|
||||
exit_date date [null, note: 'Date when member left club (must be after join_date)']
|
||||
notes text [null, note: 'Additional notes about member']
|
||||
city text [null, note: 'City of residence']
|
||||
street text [null, note: 'Street name']
|
||||
house_number text [null, note: 'House number']
|
||||
postal_code text [null, note: '5-digit German postal code']
|
||||
search_vector tsvector [null, note: 'Full-text search index (auto-generated)']
|
||||
|
||||
indexes {
|
||||
email [unique, name: 'members_unique_email_index']
|
||||
search_vector [type: gin, name: 'members_search_vector_idx', note: 'GIN index for full-text search (tsvector)']
|
||||
first_name [type: gin, name: 'members_first_name_trgm_idx', note: 'GIN trigram index for fuzzy search']
|
||||
last_name [type: gin, name: 'members_last_name_trgm_idx', note: 'GIN trigram index for fuzzy search']
|
||||
email [type: gin, name: 'members_email_trgm_idx', note: 'GIN trigram index for fuzzy search']
|
||||
city [type: gin, name: 'members_city_trgm_idx', note: 'GIN trigram index for fuzzy search']
|
||||
street [type: gin, name: 'members_street_trgm_idx', note: 'GIN trigram index for fuzzy search']
|
||||
notes [type: gin, name: 'members_notes_trgm_idx', note: 'GIN trigram index for fuzzy search']
|
||||
email [name: 'members_email_idx', note: 'B-tree index for exact lookups']
|
||||
last_name [name: 'members_last_name_idx', note: 'B-tree index for name sorting']
|
||||
join_date [name: 'members_join_date_idx', note: 'B-tree index for date filters']
|
||||
(paid) [name: 'members_paid_idx', type: btree, note: 'Partial index WHERE paid IS NOT NULL']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**Club Member Master Data**
|
||||
|
||||
Core entity for membership management containing:
|
||||
- Personal information (name, email)
|
||||
- Contact details (phone, address)
|
||||
- Membership status (join/exit dates, payment status)
|
||||
- Additional notes
|
||||
|
||||
**Email Synchronization:**
|
||||
When a member is linked to a user:
|
||||
- User.email is the source of truth (overwrites member.email on link)
|
||||
- Subsequent changes to either email sync bidirectionally
|
||||
- Validates that email is not already used by another unlinked user
|
||||
|
||||
**Search Capabilities:**
|
||||
1. Full-Text Search (tsvector):
|
||||
- `search_vector` is auto-updated via trigger
|
||||
- Weighted fields: first_name (A), last_name (A), email (B), notes (B)
|
||||
- GIN index for fast text search
|
||||
|
||||
2. Fuzzy Search (pg_trgm):
|
||||
- Trigram-based similarity matching
|
||||
- 6 GIN trigram indexes on searchable fields
|
||||
- Configurable similarity threshold (default 0.2)
|
||||
- Supports typos and partial matches
|
||||
|
||||
**Relationships:**
|
||||
- Optional 1:1 with users (0..1 ↔ 0..1) - authentication account
|
||||
- 1:N with custom_field_values (custom dynamic fields)
|
||||
|
||||
**Validation Rules:**
|
||||
- first_name, last_name: min 1 character
|
||||
- email: 5-254 characters, valid email format
|
||||
- join_date: cannot be in future
|
||||
- exit_date: must be after join_date (if both present)
|
||||
- phone_number: matches pattern ^\+?[0-9\- ]{6,20}$
|
||||
- postal_code: exactly 5 digits
|
||||
'''
|
||||
}
|
||||
|
||||
Table custom_field_values {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
|
||||
value jsonb [null, note: 'Union type value storage (format: {type: "string", value: "example"})']
|
||||
member_id uuid [not null, note: 'Link to member']
|
||||
custom_field_id uuid [not null, note: 'Link to custom field definition']
|
||||
|
||||
indexes {
|
||||
(member_id, custom_field_id) [unique, name: 'custom_field_values_unique_custom_field_per_member_index', note: 'One custom field value per custom field per member']
|
||||
member_id [name: 'custom_field_values_member_id_idx']
|
||||
custom_field_id [name: 'custom_field_values_custom_field_id_idx']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**Dynamic Custom Member Field Values**
|
||||
|
||||
Provides flexible, extensible attributes for members beyond the fixed schema.
|
||||
|
||||
**Value Storage:**
|
||||
- Stored as JSONB map with type discrimination
|
||||
- Format: `{type: "string|integer|boolean|date|email", value: <actual_value>}`
|
||||
- Allows multiple data types in single column
|
||||
|
||||
**Supported Types:**
|
||||
- `string`: Text data
|
||||
- `integer`: Numeric data
|
||||
- `boolean`: True/False flags
|
||||
- `date`: Date values
|
||||
- `email`: Validated email addresses
|
||||
|
||||
**Constraints:**
|
||||
- Each member can have only ONE custom field value per custom field
|
||||
- Custom field values are deleted when member is deleted (CASCADE)
|
||||
- Custom field cannot be deleted if custom field values exist (RESTRICT)
|
||||
|
||||
**Use Cases:**
|
||||
- Custom membership numbers
|
||||
- Additional contact methods
|
||||
- Club-specific attributes
|
||||
- Flexible data model without schema migrations
|
||||
'''
|
||||
}
|
||||
|
||||
Table custom_fields {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
|
||||
name text [not null, unique, note: 'CustomFieldValue name/identifier (e.g., "membership_number")']
|
||||
slug text [not null, unique, note: 'URL-friendly, immutable identifier (e.g., "membership-number"). Auto-generated from name.']
|
||||
value_type text [not null, note: 'Data type: string | integer | boolean | date | email']
|
||||
description text [null, note: 'Human-readable description']
|
||||
immutable boolean [not null, default: false, note: 'If true, value cannot be changed after creation']
|
||||
required boolean [not null, default: false, note: 'If true, all members must have this custom field']
|
||||
|
||||
indexes {
|
||||
name [unique, name: 'custom_fields_unique_name_index']
|
||||
slug [unique, name: 'custom_fields_unique_slug_index']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**CustomFieldValue Type Definitions**
|
||||
|
||||
Defines the schema and behavior for custom member custom_field_values.
|
||||
|
||||
**Attributes:**
|
||||
- `name`: Unique identifier for the custom field
|
||||
- `slug`: URL-friendly, human-readable identifier (auto-generated, immutable)
|
||||
- `value_type`: Enforces data type consistency
|
||||
- `description`: Documentation for users/admins
|
||||
- `immutable`: Prevents changes after initial creation (e.g., membership numbers)
|
||||
- `required`: Enforces that all members must have this custom field
|
||||
|
||||
**Slug Generation:**
|
||||
- Automatically generated from `name` on creation
|
||||
- Immutable after creation (does not change when name is updated)
|
||||
- Lowercase, spaces replaced with hyphens, special characters removed
|
||||
- UTF-8 support (ä → a, ß → ss, etc.)
|
||||
- Used for human-readable identifiers (CSV export/import, API, etc.)
|
||||
- Examples: "Mobile Phone" → "mobile-phone", "Café Müller" → "cafe-muller"
|
||||
|
||||
**Constraints:**
|
||||
- `value_type` must be one of: string, integer, boolean, date, email
|
||||
- `name` must be unique across all custom fields
|
||||
- `slug` must be unique across all custom fields
|
||||
- `slug` cannot be empty (validated on creation)
|
||||
- Cannot be deleted if custom_field_values reference it (ON DELETE RESTRICT)
|
||||
|
||||
**Examples:**
|
||||
- Membership Number (string, immutable, required) → slug: "membership-number"
|
||||
- Emergency Contact (string, mutable, optional) → slug: "emergency-contact"
|
||||
- Certified Trainer (boolean, mutable, optional) → slug: "certified-trainer"
|
||||
- Certification Date (date, immutable, optional) → slug: "certification-date"
|
||||
'''
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RELATIONSHIPS
|
||||
// ============================================
|
||||
|
||||
// Optional 1:1 User ↔ Member Link
|
||||
// - A user can have 0 or 1 linked member (optional)
|
||||
// - A member can have 0 or 1 linked user (optional)
|
||||
// - Both can exist independently
|
||||
// - ON DELETE SET NULL: User preserved when member deleted
|
||||
// - Email Synchronization: When linking occurs, user.email becomes source of truth
|
||||
Ref: users.member_id - members.id [delete: set null]
|
||||
|
||||
// Member → Properties (1:N)
|
||||
// - One member can have multiple custom_field_values
|
||||
// - Each custom field value belongs to exactly one member
|
||||
// - ON DELETE CASCADE: Properties deleted when member deleted
|
||||
// - UNIQUE constraint: One custom field value per custom field per member
|
||||
Ref: custom_field_values.member_id > members.id [delete: cascade]
|
||||
|
||||
// CustomFieldValue → CustomField (N:1)
|
||||
// - Many custom_field_values can reference one custom field
|
||||
// - CustomFieldValue type defines the schema/behavior
|
||||
// - ON DELETE RESTRICT: Cannot delete type if custom_field_values exist
|
||||
Ref: custom_field_values.custom_field_id > custom_fields.id [delete: restrict]
|
||||
|
||||
// ============================================
|
||||
// ENUMS
|
||||
// ============================================
|
||||
|
||||
// Valid data types for custom field values
|
||||
// Determines how CustomFieldValue.value is interpreted
|
||||
Enum custom_field_value_type {
|
||||
string [note: 'Text data']
|
||||
integer [note: 'Numeric data']
|
||||
boolean [note: 'True/False flags']
|
||||
date [note: 'Date values']
|
||||
email [note: 'Validated email addresses']
|
||||
}
|
||||
|
||||
// Token purposes for different authentication flows
|
||||
Enum token_purpose {
|
||||
access [note: 'Short-lived access tokens']
|
||||
refresh [note: 'Long-lived refresh tokens']
|
||||
password_reset [note: 'Password reset tokens']
|
||||
email_confirmation [note: 'Email verification tokens']
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TABLE GROUPS
|
||||
// ============================================
|
||||
|
||||
TableGroup accounts_domain {
|
||||
users
|
||||
tokens
|
||||
|
||||
Note: '''
|
||||
**Accounts Domain**
|
||||
|
||||
Handles user authentication and session management using AshAuthentication.
|
||||
Supports multiple authentication strategies (Password, OIDC).
|
||||
'''
|
||||
}
|
||||
|
||||
TableGroup membership_domain {
|
||||
members
|
||||
custom_field_values
|
||||
custom_fields
|
||||
|
||||
Note: '''
|
||||
**Membership Domain**
|
||||
|
||||
Core business logic for club membership management.
|
||||
Supports flexible, extensible member data model.
|
||||
'''
|
||||
}
|
||||
|
||||
1593
docs/development-progress-log.md
Normal file
1593
docs/development-progress-log.md
Normal file
File diff suppressed because it is too large
Load diff
49
docs/email-sync.md
Normal file
49
docs/email-sync.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
## Core Rules
|
||||
|
||||
1. **User.email is source of truth** - Always overrides member email when linking
|
||||
2. **DB constraints** - Prevent duplicates within same table (users.email, members.email)
|
||||
3. **Custom validations** - Prevent cross-table conflicts only for linked entities
|
||||
4. **Sync is bidirectional**: User ↔ Member (but User always wins on link)
|
||||
|
||||
---
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
Action: Create/Update/Link Entity with Email X
|
||||
│
|
||||
├─ Does Email X violate DB constraint (same table)?
|
||||
│ └─ YES → ❌ FAIL (two users or two members with same email)
|
||||
│
|
||||
├─ Is Entity currently linked? (or being linked?)
|
||||
│ │
|
||||
│ ├─ NO (unlinked entity)
|
||||
│ │ └─ ✅ SUCCESS (no custom validation)
|
||||
│ │
|
||||
│ └─ YES (linked or linking)
|
||||
│ │
|
||||
│ ├─ Action: Update Linked User Email
|
||||
│ │ ├─ Email used by other member? → ❌ FAIL (validation)
|
||||
│ │ └─ Email unique? → ✅ SUCCESS + sync to member
|
||||
│ │
|
||||
│ ├─ Action: Update Linked Member Email
|
||||
│ │ ├─ Email used by other user? → ❌ FAIL (validation)
|
||||
│ │ └─ Email unique? → ✅ SUCCESS + sync to user
|
||||
│ │
|
||||
│ ├─ Action: Link User to Member (both directions)
|
||||
│ │ ├─ User email used by other member? → ❌ FAIL (validation)
|
||||
│ │ └─ Otherwise → ✅ SUCCESS + override member email
|
||||
|
||||
```
|
||||
|
||||
## Sync Triggers
|
||||
|
||||
| Action | Sync Direction | When |
|
||||
|--------|---------------|------|
|
||||
| Update linked user email | User → Member | Email changed |
|
||||
| Update linked member email | Member → User | Email changed |
|
||||
| Link user to member | User → Member | Always (override) |
|
||||
| Link member to user | User → Member | Always (override) |
|
||||
| Unlink | None | Emails stay as-is |
|
||||
|
||||
|
||||
756
docs/feature-roadmap.md
Normal file
756
docs/feature-roadmap.md
Normal file
|
|
@ -0,0 +1,756 @@
|
|||
# Feature Roadmap & Implementation Plan
|
||||
|
||||
**Project:** Mila - Membership Management System
|
||||
**Last Updated:** 2025-11-10
|
||||
**Status:** Planning Phase
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Phase 1: Feature Area Breakdown](#phase-1-feature-area-breakdown)
|
||||
2. [Phase 2: API Endpoint Definition](#phase-2-api-endpoint-definition)
|
||||
3. [Phase 3: Implementation Task Creation](#phase-3-implementation-task-creation)
|
||||
4. [Phase 4: Task Organization and Prioritization](#phase-4-task-organization-and-prioritization)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Feature Area Breakdown
|
||||
|
||||
### Feature Areas
|
||||
|
||||
#### 1. **Authentication & Authorization** 🔐
|
||||
|
||||
**Current State:**
|
||||
- ✅ OIDC authentication (Rauthy)
|
||||
- ✅ Password-based authentication
|
||||
- ✅ User sessions and tokens
|
||||
- ✅ Basic authentication flows
|
||||
- ✅ **OIDC account linking with password verification** (PR #192, closes #171)
|
||||
- ✅ **Secure OIDC email collision handling** (PR #192)
|
||||
- ✅ **Automatic linking for passwordless users** (PR #192)
|
||||
|
||||
**Closed Issues:**
|
||||
- ✅ [#171](https://git.local-it.org/local-it/mitgliederverwaltung/issues/171) - OIDC handling and linking (closed 2025-11-13)
|
||||
|
||||
**Open Issues:**
|
||||
- [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen (Low)
|
||||
- [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen (Low)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Role-based access control (RBAC)
|
||||
- ❌ Permission system
|
||||
- ❌ Password reset flow
|
||||
- ❌ Email verification
|
||||
- ❌ Two-factor authentication (future)
|
||||
|
||||
**Related Issues:**
|
||||
- [#191](https://git.local-it.org/local-it/mitgliederverwaltung/issues/191) - Implement Roles in Ash (M)
|
||||
- [#190](https://git.local-it.org/local-it/mitgliederverwaltung/issues/190) - Implement Permissions in Ash (M)
|
||||
- [#151](https://git.local-it.org/local-it/mitgliederverwaltung/issues/151) - Define implementation plan for roles and permissions (M) [3/7 tasks done]
|
||||
|
||||
---
|
||||
|
||||
#### 2. **Member Management** 👥
|
||||
|
||||
**Current State:**
|
||||
- ✅ Member CRUD operations
|
||||
- ✅ Member profile with personal data
|
||||
- ✅ Address management
|
||||
- ✅ Membership status tracking
|
||||
- ✅ Full-text search (PostgreSQL tsvector)
|
||||
- ✅ **Fuzzy search with trigram matching** (PR #187, closes #162)
|
||||
- ✅ **Combined FTS + trigram search** (PR #187)
|
||||
- ✅ **6 GIN trigram indexes** for fuzzy matching (PR #187)
|
||||
- ✅ 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)
|
||||
|
||||
**Open Issues:**
|
||||
- [#169](https://git.local-it.org/local-it/mitgliederverwaltung/issues/169) - Allow combined creation of Users/Members (M, Low priority)
|
||||
- [#168](https://git.local-it.org/local-it/mitgliederverwaltung/issues/168) - Allow user-member association in edit/create views (M, High priority)
|
||||
- [#165](https://git.local-it.org/local-it/mitgliederverwaltung/issues/165) - Pagination for list of members (S, Low priority)
|
||||
- [#160](https://git.local-it.org/local-it/mitgliederverwaltung/issues/160) - Implement clear icon in searchbar (S, Low priority)
|
||||
- [#154](https://git.local-it.org/local-it/mitgliederverwaltung/issues/154) - Concept advanced search (Low priority, needs refinement)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Advanced filters (date ranges, multiple criteria)
|
||||
- ❌ Pagination (currently all members loaded)
|
||||
- ❌ Bulk operations (bulk delete, bulk update)
|
||||
- ❌ Member import/export (CSV, Excel)
|
||||
- ❌ Member profile photos/avatars
|
||||
- ❌ Member history/audit log
|
||||
- ❌ Duplicate detection
|
||||
|
||||
---
|
||||
|
||||
#### 3. **Custom Fields (CustomFieldValue System)** 🔧
|
||||
|
||||
**Current State:**
|
||||
- ✅ CustomFieldValue types (string, integer, boolean, date, email)
|
||||
- ✅ 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:**
|
||||
- [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks]
|
||||
- [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Field groups/categories
|
||||
- ❌ Conditional fields (show field X if field Y = value)
|
||||
- ❌ Field validation rules (min/max, regex patterns)
|
||||
- ❌ Required custom fields
|
||||
- ❌ Multi-select fields
|
||||
- ❌ File upload fields
|
||||
- ❌ Sorting by custom fields
|
||||
- ❌ Searching by custom fields
|
||||
|
||||
---
|
||||
|
||||
#### 4. **User Management** 👤
|
||||
|
||||
**Current State:**
|
||||
- ✅ User CRUD operations
|
||||
- ✅ User list view
|
||||
- ✅ User profile view
|
||||
- ✅ Admin password setting
|
||||
- ✅ User-Member relationship
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ User roles assignment UI
|
||||
- ❌ User permissions management
|
||||
- ❌ User activity log
|
||||
- ❌ User invitation system
|
||||
- ❌ User onboarding flow
|
||||
- ❌ Self-service profile editing
|
||||
- ❌ Password change flow
|
||||
|
||||
---
|
||||
|
||||
#### 5. **Navigation & UX** 🧭
|
||||
|
||||
**Current State:**
|
||||
- ✅ Basic navigation structure
|
||||
- ✅ Navbar with profile button
|
||||
- ✅ Member list as landing page
|
||||
- ✅ Breadcrumbs (basic)
|
||||
|
||||
**Open Issues:**
|
||||
- [#188](https://git.local-it.org/local-it/mitgliederverwaltung/issues/188) - Check if searching just on typing is accessible (S, Low priority)
|
||||
- [#174](https://git.local-it.org/local-it/mitgliederverwaltung/issues/174) - Accessibility - aria-sort in tables (S, Low priority)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Dashboard/Home page
|
||||
- ❌ Quick actions menu
|
||||
- ❌ Recent activity widget
|
||||
- ❌ Keyboard shortcuts
|
||||
- ❌ Mobile navigation
|
||||
- ❌ Context-sensitive help
|
||||
- ❌ Onboarding tooltips
|
||||
|
||||
---
|
||||
|
||||
#### 6. **Internationalization (i18n)** 🌍
|
||||
|
||||
**Current State:**
|
||||
- ✅ Gettext integration
|
||||
- ✅ German translations
|
||||
- ✅ English translations
|
||||
- ✅ Translation files for auth, errors, default
|
||||
|
||||
**Open Issues:**
|
||||
- [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen (Low)
|
||||
- [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen (Low)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Language switcher UI
|
||||
- ❌ User-specific language preferences
|
||||
- ❌ Date/time localization
|
||||
- ❌ Number formatting (currency, decimals)
|
||||
- ❌ Complete translation coverage
|
||||
- ❌ RTL support (future)
|
||||
|
||||
---
|
||||
|
||||
#### 7. **Payment & Fees Management** 💰
|
||||
|
||||
**Current State:**
|
||||
- ✅ Basic "paid" boolean field on members
|
||||
- ⚠️ 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)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Membership fee configuration
|
||||
- ❌ Payment records/transactions
|
||||
- ❌ Payment history per member
|
||||
- ❌ Payment reminders
|
||||
- ❌ Payment status tracking (pending, paid, overdue)
|
||||
- ❌ Invoice generation
|
||||
- ❌ vereinfacht.digital API integration
|
||||
- ❌ SEPA direct debit support
|
||||
- ❌ Payment reports
|
||||
|
||||
**Related Milestones:**
|
||||
- Import transactions via vereinfacht API
|
||||
|
||||
---
|
||||
|
||||
#### 8. **Admin Panel & Configuration** ⚙️
|
||||
|
||||
**Current State:**
|
||||
- ✅ AshAdmin integration (basic)
|
||||
- ⚠️ No user-facing admin UI
|
||||
|
||||
**Open Issues:**
|
||||
- [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Global settings management
|
||||
- ❌ Club/Organization profile
|
||||
- ❌ Email templates configuration
|
||||
- ❌ CustomFieldValue type management UI (user-facing)
|
||||
- ❌ Role and permission management UI
|
||||
- ❌ System health dashboard
|
||||
- ❌ Audit log viewer
|
||||
- ❌ Backup/restore functionality
|
||||
|
||||
**Related Milestones:**
|
||||
- As Admin I can configure settings globally
|
||||
|
||||
---
|
||||
|
||||
#### 9. **Communication & Notifications** 📧
|
||||
|
||||
**Current State:**
|
||||
- ✅ Swoosh mailer integration
|
||||
- ✅ Email confirmation (via AshAuthentication)
|
||||
- ✅ Password reset emails (via AshAuthentication)
|
||||
- ⚠️ No member communication features
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Email broadcast to members
|
||||
- ❌ Email templates (customizable)
|
||||
- ❌ Email to member groups/filters
|
||||
|
||||
---
|
||||
|
||||
#### 10. **Reporting & Analytics** 📊
|
||||
|
||||
**Current State:**
|
||||
- ❌ No reporting features
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Member statistics dashboard
|
||||
- ❌ Membership growth charts
|
||||
- ❌ Payment reports
|
||||
- ❌ Custom report builder
|
||||
- ❌ Export to PDF/CSV/Excel
|
||||
- ❌ Scheduled reports
|
||||
- ❌ Data visualization
|
||||
|
||||
---
|
||||
|
||||
#### 11. **Data Import/Export** 📥📤
|
||||
|
||||
**Current State:**
|
||||
- ✅ Seed data script
|
||||
- ⚠️ No user-facing import/export
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ CSV import for members
|
||||
- ❌ Excel import for members
|
||||
- ❌ Import validation and preview
|
||||
- ❌ Import error handling
|
||||
- ❌ Bulk data export
|
||||
- ❌ Backup export
|
||||
- ❌ Data migration tools
|
||||
|
||||
---
|
||||
|
||||
#### 12. **Testing & Quality Assurance** 🧪
|
||||
|
||||
**Current State:**
|
||||
- ✅ ExUnit test suite
|
||||
- ✅ Unit tests for resources
|
||||
- ✅ Integration tests for email sync
|
||||
- ✅ LiveView tests
|
||||
- ✅ Component tests
|
||||
- ✅ CI/CD pipeline (Drone)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ E2E tests (browser automation)
|
||||
- ❌ Performance testing
|
||||
- ❌ Load testing
|
||||
- ❌ Security penetration testing
|
||||
- ❌ Accessibility testing automation
|
||||
- ❌ Visual regression testing
|
||||
- ❌ Test coverage reporting
|
||||
|
||||
---
|
||||
|
||||
#### 13. **Infrastructure & DevOps** 🚀
|
||||
|
||||
**Current State:**
|
||||
- ✅ Docker Compose for development
|
||||
- ✅ Production Dockerfile
|
||||
- ✅ Drone CI/CD pipeline
|
||||
- ✅ Renovate for dependency updates
|
||||
- ⚠️ No staging environment
|
||||
|
||||
**Open Issues:**
|
||||
- [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Staging environment
|
||||
- ❌ Automated deployment
|
||||
- ❌ Database backup automation
|
||||
- ❌ Monitoring and alerting
|
||||
- ❌ Error tracking (Sentry, etc.)
|
||||
- ❌ Log aggregation
|
||||
- ❌ Health checks and uptime monitoring
|
||||
|
||||
**Related Milestones:**
|
||||
- We have a staging environment
|
||||
- We implement security measures
|
||||
|
||||
---
|
||||
|
||||
#### 14. **Security & Compliance** 🔒
|
||||
|
||||
**Current State:**
|
||||
- ✅ OIDC authentication
|
||||
- ✅ Password hashing (bcrypt)
|
||||
- ✅ CSRF protection
|
||||
- ✅ SQL injection prevention (Ecto)
|
||||
- ✅ Sobelow security scans
|
||||
- ✅ Dependency auditing
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Role-based access control (see #1)
|
||||
- ❌ Audit logging
|
||||
- ❌ GDPR compliance features (data export, deletion)
|
||||
- ❌ Session management (timeout, concurrent sessions)
|
||||
- ❌ Rate limiting
|
||||
- ❌ IP whitelisting/blacklisting
|
||||
- ❌ Security headers configuration
|
||||
- ❌ Data retention policies
|
||||
|
||||
**Related Milestones:**
|
||||
- We implement security measures
|
||||
|
||||
---
|
||||
|
||||
#### 15. **Accessibility & Usability** ♿
|
||||
|
||||
**Current State:**
|
||||
- ✅ Semantic HTML
|
||||
- ✅ Basic ARIA labels
|
||||
- ⚠️ Needs comprehensive audit
|
||||
|
||||
**Open Issues:**
|
||||
- [#188](https://git.local-it.org/local-it/mitgliederverwaltung/issues/188) - Check if searching just on typing is accessible (S, Low priority)
|
||||
- [#174](https://git.local-it.org/local-it/mitgliederverwaltung/issues/174) - Accessibility - aria-sort in tables (S, Low priority)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Comprehensive accessibility audit (WCAG 2.1 Level AA)
|
||||
- ❌ Keyboard navigation improvements
|
||||
- ❌ Screen reader optimization
|
||||
- ❌ High contrast mode
|
||||
- ❌ Font size adjustments
|
||||
- ❌ Focus management
|
||||
- ❌ Skip links
|
||||
- ❌ Error announcements
|
||||
|
||||
---
|
||||
|
||||
### Feature Area Summary
|
||||
|
||||
| Feature Area | Current Status | Priority | Complexity |
|
||||
|--------------|----------------|----------|------------|
|
||||
| **Authentication & Authorization** | 60% complete | **High** | Medium |
|
||||
| **Member Management** | 85% complete | **High** | Low-Medium |
|
||||
| **Custom Fields** | 50% complete | **High** | Medium |
|
||||
| **User Management** | 60% complete | Medium | Low |
|
||||
| **Navigation & UX** | 50% complete | Medium | Low |
|
||||
| **Internationalization** | 70% complete | Low | Low |
|
||||
| **Payment & Fees** | 5% complete | **High** | High |
|
||||
| **Admin Panel** | 20% complete | Medium | Medium |
|
||||
| **Communication** | 30% complete | Medium | Medium |
|
||||
| **Reporting** | 0% complete | Medium | Medium-High |
|
||||
| **Import/Export** | 10% complete | Low | Medium |
|
||||
| **Testing & QA** | 60% complete | Medium | Low-Medium |
|
||||
| **Infrastructure** | 70% complete | Medium | Medium |
|
||||
| **Security** | 50% complete | **High** | Medium-High |
|
||||
| **Accessibility** | 40% complete | Medium | Medium |
|
||||
|
||||
---
|
||||
|
||||
### Open Milestones (From Issues)
|
||||
|
||||
1. ✅ **Ich kann einen neuen Kontakt anlegen** (Closed)
|
||||
2. ✅ **I can search through the list of members - fulltext** (Closed) - #162 implemented (Fuzzy Search), #154 needs refinement
|
||||
3. 🔄 **I can sort the list of members for specific fields** (Open) - Related: #153
|
||||
4. 🔄 **We have a intuitive navigation structure** (Open)
|
||||
5. 🔄 **We have different roles and permissions** (Open) - Related: #191, #190, #151
|
||||
6. 🔄 **As Admin I can configure settings globally** (Open)
|
||||
7. ✅ **Accounts & Logins** (Partially closed) - #171 implemented (OIDC linking), #169/#168 still open
|
||||
8. 🔄 **I can add custom fields** (Open) - Related: #194, #157, #161
|
||||
9. 🔄 **Import transactions via vereinfacht API** (Open) - Related: #156
|
||||
10. 🔄 **We have a staging environment** (Open)
|
||||
11. 🔄 **We implement security measures** (Open)
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: API Endpoint Definition
|
||||
|
||||
### Endpoint Types
|
||||
|
||||
Since this is a **Phoenix LiveView** application with **Ash Framework**, we have three types of endpoints:
|
||||
|
||||
1. **LiveView Endpoints** - Mount points and event handlers
|
||||
2. **HTTP Controller Endpoints** - Traditional REST-style endpoints
|
||||
3. **Ash Resource Actions** - Backend data layer API
|
||||
|
||||
### Authentication Requirements Legend
|
||||
|
||||
- 🔓 **Public** - No authentication required
|
||||
- 🔐 **Authenticated** - Requires valid user session
|
||||
- 👤 **User Role** - Requires specific user role
|
||||
- 🛡️ **Admin Only** - Requires admin privileges
|
||||
|
||||
---
|
||||
|
||||
### 1. Authentication & Authorization Endpoints
|
||||
|
||||
#### HTTP Controller Endpoints
|
||||
|
||||
| Method | Route | Purpose | Auth | Request | Response |
|
||||
|--------|-------|---------|------|---------|----------|
|
||||
| `GET` | `/auth/user/password/sign_in` | Show password login form | 🔓 | - | HTML form |
|
||||
| `POST` | `/auth/user/password/sign_in` | Submit password login | 🔓 | `{email, password}` | Redirect + session cookie |
|
||||
| `GET` | `/auth/user/rauthy` | Initiate OIDC flow | 🔓 | - | Redirect to Rauthy |
|
||||
| `GET` | `/auth/user/rauthy/callback` | Handle OIDC callback | 🔓 | `{code, state}` | Redirect + session cookie |
|
||||
| `POST` | `/auth/user/sign_out` | Sign out user | 🔐 | - | Redirect to login |
|
||||
| `GET` | `/auth/user/password/reset` | Show password reset form | 🔓 | - | HTML form |
|
||||
| `POST` | `/auth/user/password/reset` | Request password reset | 🔓 | `{email}` | Success message + email sent |
|
||||
| `GET` | `/auth/user/password/reset/:token` | Show reset password form | 🔓 | - | HTML form |
|
||||
| `POST` | `/auth/user/password/reset/:token` | Submit new password | 🔓 | `{password, password_confirmation}` | Redirect to login |
|
||||
|
||||
#### Ash Resource Actions
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `User` | `:sign_in_with_password` | Password authentication | 🔓 | `{email, password}` | `{:ok, user}` or `{:error, reason}` |
|
||||
| `User` | `:sign_in_with_rauthy` | OIDC authentication | 🔓 | `{oidc_id, email, user_info}` | `{:ok, user}` or `{:error, reason}` |
|
||||
| `User` | `:register_with_password` | Create user with password | 🔓 | `{email, password}` | `{:ok, user}` |
|
||||
| `User` | `:register_with_rauthy` | Create user via OIDC | 🔓 | `{oidc_id, email}` | `{:ok, user}` |
|
||||
| `User` | `: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}` |
|
||||
|
||||
#### **NEW: Role & Permission Actions** (Issue #191, #190, #151)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `Role` | `:create` | Create new role | 🛡️ | `{name, description, permissions}` | `{:ok, role}` |
|
||||
| `Role` | `:list` | List all roles | 🔐 | - | `[%Role{}]` |
|
||||
| `Role` | `:update` | Update role | 🛡️ | `{id, name, permissions}` | `{:ok, role}` |
|
||||
| `Role` | `:delete` | Delete role | 🛡️ | `{id}` | `{:ok, role}` |
|
||||
| `User` | `:assign_role` | Assign role to user | 🛡️ | `{user_id, role_id}` | `{:ok, user}` |
|
||||
| `User` | `:remove_role` | Remove role from user | 🛡️ | `{user_id, role_id}` | `{:ok, user}` |
|
||||
| `Permission` | `:list` | List all permissions | 🔐 | - | `[%Permission{}]` |
|
||||
| `Permission` | `:check` | Check user permission | 🔐 | `{user_id, resource, action}` | `{:ok, boolean}` |
|
||||
|
||||
---
|
||||
|
||||
### 2. Member Management Endpoints
|
||||
|
||||
#### LiveView Endpoints
|
||||
|
||||
| Mount | Purpose | Auth | Query Params | Events |
|
||||
|-------|---------|------|--------------|--------|
|
||||
| `/members` | Member list with search/sort | 🔐 | `?search=&sort_by=&sort_dir=` | `search`, `sort`, `delete`, `select` |
|
||||
| `/members/new` | Create new member form | 🔐 | - | `save`, `cancel`, `add_custom_field_value` |
|
||||
| `/members/:id` | Member detail view | 🔐 | - | `edit`, `delete`, `link_user` |
|
||||
| `/members/:id/edit` | Edit member form | 🔐 | - | `save`, `cancel`, `add_custom_field_value`, `remove_custom_field_value` |
|
||||
|
||||
#### LiveView Event Handlers
|
||||
|
||||
| Event | Purpose | Params | Response |
|
||||
|-------|---------|--------|----------|
|
||||
| `search` | Trigger search | `%{"search" => query}` | Update member list |
|
||||
| `sort` | Sort member list | `%{"field" => field}` | Update sorted list |
|
||||
| `delete` | Delete member | `%{"id" => id}` | Redirect to list |
|
||||
| `save` | Create/update member | `%{"member" => attrs}` | Redirect or show errors |
|
||||
| `link_user` | Link user to member | `%{"user_id" => id}` | Update member view |
|
||||
| `unlink_user` | Unlink user from member | - | Update member view |
|
||||
| `add_custom_field_value` | Add custom field value | `%{"custom_field_id" => id, "value" => val}` | Update form |
|
||||
| `remove_custom_field_value` | Remove custom field value | `%{"custom_field_value_id" => id}` | Update form |
|
||||
|
||||
#### Ash Resource Actions
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `Member` | `:create_member` | Create member | 🔐 | `{first_name, last_name, email, ...}` | `{:ok, member}` |
|
||||
| `Member` | `:read` | List/search members | 🔐 | `{search, sort_by, limit, offset}` | `[%Member{}]` |
|
||||
| `Member` | `:update_member` | Update member | 🔐 | `{id, attrs}` | `{:ok, member}` |
|
||||
| `Member` | `:destroy` | Delete member | 🔐 | `{id}` | `{:ok, member}` |
|
||||
| `Member` | `:search_fulltext` | Full-text search | 🔐 | `{query}` | `[%Member{}]` |
|
||||
| `Member` | `:link_to_user` | Link member to user | 🔐 | `{member_id, user_id}` | `{:ok, member}` |
|
||||
| `Member` | `:unlink_from_user` | Unlink from user | 🔐 | `{member_id}` | `{:ok, member}` |
|
||||
|
||||
#### **NEW: Enhanced Search & Filter Actions** (Issue #162, #154, #165)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `Member` | `:fuzzy_search` | Fuzzy text search | 🔐 | `{query, threshold}` | `[%Member{}]` |
|
||||
| `Member` | `:advanced_search` | Multi-criteria search | 🔐 | `{filters: [{field, op, value}]}` | `[%Member{}]` |
|
||||
| `Member` | `:paginate` | Paginated member list | 🔐 | `{page, per_page, filters}` | `{members, total, page_info}` |
|
||||
| `Member` | `:sort_by_custom_field` | Sort by custom field | 🔐 | `{custom_field_id, direction}` | `[%Member{}]` |
|
||||
| `Member` | `:bulk_delete` | Delete multiple members | 🛡️ | `{ids: [id1, id2, ...]}` | `{:ok, count}` |
|
||||
| `Member` | `:bulk_update` | Update multiple members | 🛡️ | `{ids, attrs}` | `{:ok, count}` |
|
||||
| `Member` | `:export` | Export to CSV/Excel | 🔐 | `{format, filters}` | File download |
|
||||
| `Member` | `:import` | Import from CSV | 🛡️ | `{file, mapping}` | `{:ok, imported_count, errors}` |
|
||||
|
||||
---
|
||||
|
||||
### 3. Custom Fields (CustomFieldValue System) Endpoints
|
||||
|
||||
#### LiveView Endpoints
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/custom-fields` | List custom fields | 🛡️ | `new`, `edit`, `delete` |
|
||||
| `/custom-fields/new` | Create custom field | 🛡️ | `save`, `cancel` |
|
||||
| `/custom-fields/:id/edit` | Edit custom field | 🛡️ | `save`, `cancel`, `delete` |
|
||||
|
||||
#### Ash Resource Actions
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `CustomField` | `:create` | Create custom field | 🛡️ | `{name, value_type, description, ...}` | `{:ok, custom_field}` |
|
||||
| `CustomField` | `:read` | List custom fields | 🔐 | - | `[%CustomField{}]` |
|
||||
| `CustomField` | `:update` | Update custom field | 🛡️ | `{id, attrs}` | `{:ok, custom_field}` |
|
||||
| `CustomField` | `:destroy` | Delete custom field | 🛡️ | `{id}` | `{:ok, custom_field}` |
|
||||
| `CustomFieldValue` | `:create` | Add custom field value to member | 🔐 | `{member_id, custom_field_id, value}` | `{:ok, custom_field_value}` |
|
||||
| `CustomFieldValue` | `:update` | Update custom field value | 🔐 | `{id, value}` | `{:ok, custom_field_value}` |
|
||||
| `CustomFieldValue` | `:destroy` | Remove custom field value | 🔐 | `{id}` | `{:ok, custom_field_value}` |
|
||||
|
||||
#### **NEW: Enhanced Custom Fields** (Issue #194, #157, #161, #153)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `CustomField` | `:set_default_visibility` | Show/hide by default | 🛡️ | `{id, visible}` | `{:ok, custom_field}` |
|
||||
| `CustomField` | `:set_required` | Mark as required | 🛡️ | `{id, required}` | `{:ok, custom_field}` |
|
||||
| `CustomField` | `:add_validation` | Add validation rule | 🛡️ | `{id, rule_type, params}` | `{:ok, custom_field}` |
|
||||
| `CustomField` | `:create_group` | Create field group | 🛡️ | `{name, custom_field_ids}` | `{:ok, group}` |
|
||||
| `CustomFieldValue` | `:validate_value` | Validate custom field value | 🔐 | `{custom_field_id, value}` | `{:ok, valid}` or `{:error, reason}` |
|
||||
|
||||
---
|
||||
|
||||
### 4. User Management Endpoints
|
||||
|
||||
#### LiveView Endpoints
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/users` | User list | 🛡️ | `new`, `edit`, `delete`, `assign_role` |
|
||||
| `/users/new` | Create user form | 🛡️ | `save`, `cancel` |
|
||||
| `/users/:id` | User detail view | 🔐 | `edit`, `delete`, `change_password` |
|
||||
| `/users/:id/edit` | Edit user form | 🔐 | `save`, `cancel`, `link_member` |
|
||||
| `/profile` | Current user profile | 🔐 | `edit`, `change_password` |
|
||||
|
||||
#### Ash Resource Actions
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `User` | `:create_user` | Create user (admin) | 🛡️ | `{email, member_id?}` | `{:ok, user}` |
|
||||
| `User` | `:read` | List users | 🛡️ | - | `[%User{}]` |
|
||||
| `User` | `:update_user` | Update user | 🔐 | `{id, email, member_id?}` | `{:ok, user}` |
|
||||
| `User` | `:destroy` | Delete user | 🛡️ | `{id}` | `{:ok, user}` |
|
||||
| `User` | `:admin_set_password` | Set password (admin) | 🛡️ | `{id, password}` | `{:ok, user}` |
|
||||
| `User` | `:change_password` | Change own password | 🔐 | `{current_password, new_password}` | `{:ok, user}` |
|
||||
|
||||
#### **NEW: Combined User/Member Management** (Issue #169, #168)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `User` | `:create_with_member` | Create user + member together | 🛡️ | `{user: {...}, member: {...}}` | `{:ok, %{user, member}}` |
|
||||
| `User` | `:invite_user` | Send invitation email | 🛡️ | `{email, role_id, member_id?}` | `{:ok, invitation}` |
|
||||
| `User` | `:accept_invitation` | Accept invitation | 🔓 | `{token, password}` | `{:ok, user}` |
|
||||
|
||||
---
|
||||
|
||||
### 5. Navigation & UX Endpoints
|
||||
|
||||
#### LiveView Endpoints
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/` | Dashboard/Home | 🔐 | - |
|
||||
| `/dashboard` | Dashboard view | 🔐 | Contextual based on role |
|
||||
|
||||
#### HTTP Controller Endpoints
|
||||
|
||||
| Method | Route | Purpose | Auth | Request | Response |
|
||||
|--------|-------|---------|------|---------|----------|
|
||||
| `GET` | `/health` | Health check | 🔓 | - | `{"status": "ok"}` |
|
||||
| `GET` | `/` | Root redirect | - | - | Redirect to dashboard or login |
|
||||
|
||||
---
|
||||
|
||||
### 6. Internationalization Endpoints
|
||||
|
||||
#### HTTP Controller Endpoints
|
||||
|
||||
| Method | Route | Purpose | Auth | Request | Response |
|
||||
|--------|-------|---------|------|---------|----------|
|
||||
| `POST` | `/locale` | Set user locale | 🔐 | `{locale: "de"}` | Redirect with cookie |
|
||||
| `GET` | `/locales` | List available locales | 🔓 | - | `["de", "en"]` |
|
||||
|
||||
---
|
||||
|
||||
### 7. Payment & Fees Management Endpoints
|
||||
|
||||
#### LiveView Endpoints (NEW - Issue #156)
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/payments` | Payment list | 🔐 | `new`, `record_payment`, `send_reminder` |
|
||||
| `/payments/:id` | Payment detail | 🔐 | `edit`, `delete`, `mark_paid` |
|
||||
| `/fees` | Fee configuration | 🛡️ | `create`, `edit`, `delete` |
|
||||
| `/invoices` | Invoice list | 🔐 | `generate`, `download`, `send` |
|
||||
|
||||
#### Ash Resource Actions (NEW)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `Fee` | `:create` | Create fee type | 🛡️ | `{name, amount, frequency}` | `{:ok, fee}` |
|
||||
| `Fee` | `:read` | List fees | 🔐 | - | `[%Fee{}]` |
|
||||
| `Payment` | `:create` | Record payment | 🔐 | `{member_id, fee_id, amount, date}` | `{:ok, payment}` |
|
||||
| `Payment` | `:list_by_member` | Member payment history | 🔐 | `{member_id}` | `[%Payment{}]` |
|
||||
| `Payment` | `:mark_paid` | Mark as paid | 🔐 | `{id}` | `{:ok, payment}` |
|
||||
| `Invoice` | `:generate` | Generate invoice | 🔐 | `{member_id, fee_id, period}` | `{:ok, invoice}` |
|
||||
| `Invoice` | `:send` | Send invoice via email | 🔐 | `{id}` | `{:ok, sent}` |
|
||||
| `Payment` | `:import_vereinfacht` | Import from vereinfacht.digital | 🛡️ | `{transactions}` | `{:ok, count}` |
|
||||
|
||||
---
|
||||
|
||||
### 8. Admin Panel & Configuration Endpoints
|
||||
|
||||
#### LiveView Endpoints (NEW)
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/admin` | Admin dashboard | 🛡️ | - |
|
||||
| `/admin/settings` | Global settings | 🛡️ | `save` |
|
||||
| `/admin/organization` | Organization profile | 🛡️ | `save` |
|
||||
| `/admin/email-templates` | Email template editor | 🛡️ | `create`, `edit`, `preview` |
|
||||
| `/admin/audit-log` | System audit log | 🛡️ | `filter`, `export` |
|
||||
|
||||
#### Ash Resource Actions (NEW)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `Setting` | `:get` | Get setting value | 🔐 | `{key}` | `value` |
|
||||
| `Setting` | `:set` | Set setting value | 🛡️ | `{key, value}` | `{:ok, setting}` |
|
||||
| `Setting` | `:list` | List all settings | 🛡️ | - | `[%Setting{}]` |
|
||||
| `Organization` | `:read` | Get organization info | 🔐 | - | `%Organization{}` |
|
||||
| `Organization` | `:update` | Update organization | 🛡️ | `{name, logo, ...}` | `{:ok, org}` |
|
||||
| `AuditLog` | `:list` | List audit entries | 🛡️ | `{filters, pagination}` | `[%AuditLog{}]` |
|
||||
|
||||
---
|
||||
|
||||
### 9. Communication & Notifications Endpoints
|
||||
|
||||
#### LiveView Endpoints (NEW)
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/communications` | Communication history | 🔐 | `new`, `view` |
|
||||
| `/communications/new` | Create email broadcast | 🔐 | `select_recipients`, `preview`, `send` |
|
||||
| `/notifications` | User notifications | 🔐 | `mark_read`, `mark_all_read` |
|
||||
|
||||
#### Ash Resource Actions (NEW)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `EmailBroadcast` | `:create` | Create broadcast | 🔐 | `{subject, body, recipient_filter}` | `{:ok, broadcast}` |
|
||||
| `EmailBroadcast` | `:send` | Send broadcast | 🔐 | `{id}` | `{:ok, sent_count}` |
|
||||
| `EmailTemplate` | `:create` | Create template | 🛡️ | `{name, subject, body}` | `{:ok, template}` |
|
||||
| `EmailTemplate` | `:render` | Render template | 🔐 | `{id, variables}` | `rendered_html` |
|
||||
| `Notification` | `:create` | Create notification | System | `{user_id, type, message}` | `{:ok, notification}` |
|
||||
| `Notification` | `:list_for_user` | Get user notifications | 🔐 | `{user_id}` | `[%Notification{}]` |
|
||||
| `Notification` | `:mark_read` | Mark as read | 🔐 | `{id}` | `{:ok, notification}` |
|
||||
|
||||
---
|
||||
|
||||
### 10. Reporting & Analytics Endpoints
|
||||
|
||||
#### LiveView Endpoints (NEW)
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/reports` | Reports dashboard | 🔐 | `generate`, `schedule` |
|
||||
| `/reports/members` | Member statistics | 🔐 | `filter`, `export` |
|
||||
| `/reports/payments` | Payment reports | 🔐 | `filter`, `export` |
|
||||
| `/reports/custom` | Custom report builder | 🛡️ | `build`, `save`, `run` |
|
||||
|
||||
#### Ash Resource Actions (NEW)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `Report` | `:generate_member_stats` | Member statistics | 🔐 | `{date_range, filters}` | Statistics object |
|
||||
| `Report` | `:generate_payment_stats` | Payment statistics | 🔐 | `{date_range}` | Statistics object |
|
||||
| `Report` | `:export_to_csv` | Export report to CSV | 🔐 | `{report_type, filters}` | CSV file |
|
||||
| `Report` | `:export_to_pdf` | Export report to PDF | 🔐 | `{report_type, filters}` | PDF file |
|
||||
| `Report` | `:schedule` | Schedule recurring report | 🛡️ | `{report_type, frequency, recipients}` | `{:ok, schedule}` |
|
||||
|
||||
---
|
||||
|
||||
### 11. Data Import/Export Endpoints
|
||||
|
||||
#### LiveView Endpoints (NEW)
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/import` | Data import wizard | 🛡️ | `upload`, `map_fields`, `preview`, `import` |
|
||||
| `/export` | Data export tool | 🔐 | `select_data`, `configure`, `export` |
|
||||
|
||||
#### Ash Resource Actions (NEW)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `Member` | `:import_csv` | Import members from CSV | 🛡️ | `{file, field_mapping}` | `{:ok, imported, errors}` |
|
||||
| `Member` | `:validate_import` | Validate import data | 🛡️ | `{file, field_mapping}` | `{:ok, validation_results}` |
|
||||
| `Member` | `:export_csv` | Export members to CSV | 🔐 | `{filters}` | CSV file |
|
||||
| `Member` | `:export_excel` | Export members to Excel | 🔐 | `{filters}` | Excel file |
|
||||
| `Database` | `:export_backup` | Full database backup | 🛡️ | - | Backup file |
|
||||
| `Database` | `:import_backup` | Restore from backup | 🛡️ | `{file}` | `{:ok, restored}` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
---
|
||||
|
||||
**References:**
|
||||
- Open Issues: https://git.local-it.org/local-it/mitgliederverwaltung/issues
|
||||
- Project Board: Sprint 8 (23.10 - 13.11)
|
||||
- Architecture: See [`CODE_GUIDELINES.md`](../CODE_GUIDELINES.md)
|
||||
- Database Schema: See [`database-schema-readme.md`](database-schema-readme.md)
|
||||
|
||||
207
docs/oidc-account-linking.md
Normal file
207
docs/oidc-account-linking.md
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
# OIDC Account Linking Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This feature implements secure account linking between password-based accounts and OIDC authentication. When a user attempts to log in via OIDC with an email that already exists as a password-only account, the system requires password verification before linking the accounts.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Key Components
|
||||
|
||||
#### 1. Security Fix: `lib/accounts/user.ex`
|
||||
|
||||
**Change**: The `sign_in_with_rauthy` action now filters by `oidc_id` instead of `email`.
|
||||
|
||||
```elixir
|
||||
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
|
||||
# SECURITY: Filter by oidc_id, NOT by email!
|
||||
filter expr(oidc_id == get_path(^arg(:user_info), [:sub]))
|
||||
end
|
||||
```
|
||||
|
||||
**Why**: Prevents OIDC users from bypassing password authentication and taking over existing accounts.
|
||||
|
||||
#### 2. Custom Error: `lib/accounts/user/errors/password_verification_required.ex`
|
||||
|
||||
Custom error raised when OIDC login conflicts with existing password account.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `user_id`: ID of the existing user
|
||||
- `oidc_user_info`: OIDC user information for account linking
|
||||
|
||||
#### 3. Validation: `lib/accounts/user/validations/oidc_email_collision.ex`
|
||||
|
||||
Validates email uniqueness during OIDC registration.
|
||||
|
||||
**Scenarios**:
|
||||
|
||||
1. **User exists with matching `oidc_id`**: Allow (upsert)
|
||||
2. **User exists without `oidc_id`** (password-protected OR passwordless): Raise `PasswordVerificationRequired`
|
||||
- The `LinkOidcAccountLive` will auto-link passwordless users without password prompt
|
||||
- Password-protected users must verify their password
|
||||
3. **User exists with different `oidc_id`**: Hard error (cannot link multiple OIDC providers)
|
||||
4. **No user exists**: Allow (new user creation)
|
||||
|
||||
#### 4. Account Linking Action: `lib/accounts/user.ex`
|
||||
|
||||
```elixir
|
||||
update :link_oidc_id do
|
||||
description "Links an OIDC ID to an existing user after password verification"
|
||||
accept []
|
||||
argument :oidc_id, :string, allow_nil?: false
|
||||
argument :oidc_user_info, :map, allow_nil?: false
|
||||
# ... implementation
|
||||
end
|
||||
```
|
||||
|
||||
**Features**:
|
||||
|
||||
- Links `oidc_id` to existing user
|
||||
- Updates email if it differs from OIDC provider
|
||||
- Syncs email changes to linked member
|
||||
|
||||
#### 5. Controller: `lib/mv_web/controllers/auth_controller.ex`
|
||||
|
||||
Refactored for better complexity and maintainability.
|
||||
|
||||
**Key improvements**:
|
||||
|
||||
- Reduced cyclomatic complexity from 11 to below 9
|
||||
- Better separation of concerns with helper functions
|
||||
- Comprehensive documentation
|
||||
|
||||
**Flow**:
|
||||
|
||||
1. Detects `PasswordVerificationRequired` error
|
||||
2. Stores OIDC info in session
|
||||
3. Redirects to account linking page
|
||||
|
||||
#### 6. LiveView: `lib/mv_web/live/auth/link_oidc_account_live.ex`
|
||||
|
||||
Interactive UI for password verification and account linking.
|
||||
|
||||
**Flow**:
|
||||
|
||||
1. Retrieves OIDC info from session
|
||||
2. **Auto-links passwordless users** immediately (no password prompt)
|
||||
3. Displays password verification form for password-protected users
|
||||
4. Verifies password using AshAuthentication
|
||||
5. Links OIDC account on success
|
||||
6. Redirects to complete OIDC login
|
||||
7. **Logs all security-relevant events** (successful/failed linking attempts)
|
||||
|
||||
### Locale Persistence
|
||||
|
||||
**Problem**: Locale was lost on logout (session cleared).
|
||||
|
||||
**Solution**: Store locale in persistent cookie (1 year TTL) with security flags.
|
||||
|
||||
**Changes**:
|
||||
|
||||
- `lib/mv_web/locale_controller.ex`: Sets locale cookie with `http_only` and `secure` flags
|
||||
- `lib/mv_web/router.ex`: Reads locale from cookie if session empty
|
||||
|
||||
**Security Features**:
|
||||
- `http_only: true` - Cookie not accessible via JavaScript (XSS protection)
|
||||
- `secure: true` - Cookie only transmitted over HTTPS in production
|
||||
- `same_site: "Lax"` - CSRF protection
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### 1. OIDC ID Matching
|
||||
|
||||
- **Before**: Matched by email (vulnerable to account takeover)
|
||||
- **After**: Matched by `oidc_id` (secure)
|
||||
|
||||
### 2. Account Linking Flow
|
||||
|
||||
- Password verification required before linking (for password-protected users)
|
||||
- Passwordless users are auto-linked immediately (secure, as they have no password)
|
||||
- OIDC info stored in session (not in URL/query params)
|
||||
- CSRF protection on all forms
|
||||
- All linking attempts logged for audit trail
|
||||
|
||||
### 3. Email Updates
|
||||
|
||||
- Email updates from OIDC provider are applied during linking
|
||||
- Email changes sync to linked member (if exists)
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
- Internal errors are logged but not exposed to users (prevents information disclosure)
|
||||
- User-friendly error messages shown in UI
|
||||
- Security-relevant events logged with appropriate levels:
|
||||
- `Logger.info` for successful operations
|
||||
- `Logger.warning` for failed authentication attempts
|
||||
- `Logger.error` for system errors
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Scenario 1: New OIDC User
|
||||
|
||||
```elixir
|
||||
# User signs in with OIDC for the first time
|
||||
# → New user created with oidc_id
|
||||
```
|
||||
|
||||
### Scenario 2: Existing OIDC User
|
||||
|
||||
```elixir
|
||||
# User with oidc_id signs in via OIDC
|
||||
# → Matched by oidc_id, email updated if changed
|
||||
```
|
||||
|
||||
### Scenario 3: Password User + OIDC Login
|
||||
|
||||
```elixir
|
||||
# User with password account tries OIDC login
|
||||
# → PasswordVerificationRequired raised
|
||||
# → Redirected to /auth/link-oidc-account
|
||||
# → User enters password
|
||||
# → Password verified and logged
|
||||
# → oidc_id linked to account
|
||||
# → Successful linking logged
|
||||
# → Redirected to complete OIDC login
|
||||
```
|
||||
|
||||
### Scenario 4: Passwordless User + OIDC Login
|
||||
|
||||
```elixir
|
||||
# User without password (invited user) tries OIDC login
|
||||
# → PasswordVerificationRequired raised
|
||||
# → Redirected to /auth/link-oidc-account
|
||||
# → System detects passwordless user
|
||||
# → oidc_id automatically linked (no password prompt)
|
||||
# → Auto-linking logged
|
||||
# → Redirected to complete OIDC login
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Custom Actions
|
||||
|
||||
#### `link_oidc_id`
|
||||
|
||||
Links an OIDC ID to existing user after password verification.
|
||||
|
||||
**Arguments**:
|
||||
|
||||
- `oidc_id` (required): OIDC sub/id from provider
|
||||
- `oidc_user_info` (required): Full OIDC user info map
|
||||
|
||||
**Returns**: Updated user with linked `oidc_id`
|
||||
|
||||
**Side Effects**:
|
||||
|
||||
- Updates email if different from OIDC provider
|
||||
- Syncs email to linked member (if exists)
|
||||
|
||||
## References
|
||||
|
||||
- [AshAuthentication Documentation](https://hexdocs.pm/ash_authentication)
|
||||
- [OIDC Specification](https://openid.net/specs/openid-connect-core-1_0.html)
|
||||
- [Security Best Practices for Account Linking](https://cheatsheetseries.owasp.org/cheatsheets/Credential_Stuffing_Prevention_Cheat_Sheet.html)
|
||||
2502
docs/roles-and-permissions-architecture.md
Normal file
2502
docs/roles-and-permissions-architecture.md
Normal file
File diff suppressed because it is too large
Load diff
1653
docs/roles-and-permissions-implementation-plan.md
Normal file
1653
docs/roles-and-permissions-implementation-plan.md
Normal file
File diff suppressed because it is too large
Load diff
506
docs/roles-and-permissions-overview.md
Normal file
506
docs/roles-and-permissions-overview.md
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
# Roles and Permissions - Architecture Overview
|
||||
|
||||
**Project:** Mila - Membership Management System
|
||||
**Feature:** Role-Based Access Control (RBAC) with Hardcoded Permission Sets
|
||||
**Version:** 2.0
|
||||
**Last Updated:** 2025-11-13
|
||||
**Status:** Architecture Design - MVP Approach
|
||||
|
||||
---
|
||||
|
||||
## Purpose of This Document
|
||||
|
||||
This document provides a high-level, conceptual overview of the Roles and Permissions architecture without code examples. It is designed for quick understanding of architectural decisions and concepts.
|
||||
|
||||
**For detailed technical implementation:** See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Requirements Summary](#requirements-summary)
|
||||
3. [Evaluated Approaches](#evaluated-approaches)
|
||||
4. [Selected Architecture](#selected-architecture)
|
||||
5. [Permission System Design](#permission-system-design)
|
||||
6. [User-Member Linking Strategy](#user-member-linking-strategy)
|
||||
7. [Field-Level Permissions Strategy](#field-level-permissions-strategy)
|
||||
8. [Migration Strategy](#migration-strategy)
|
||||
9. [Related Documents](#related-documents)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Mila membership management system requires a flexible authorization system that controls:
|
||||
- **Who** can access **what** resources
|
||||
- **Which** pages users can view
|
||||
- **How** users interact with their own vs. others' data
|
||||
|
||||
### Key Design Principles
|
||||
|
||||
1. **Simplicity First:** Start with hardcoded permissions for fast MVP delivery
|
||||
2. **Performance:** No database queries for permission checks in MVP
|
||||
3. **Clear Migration Path:** Easy upgrade to database-backed permissions when needed
|
||||
4. **Security:** Explicit action-based authorization with no ambiguity
|
||||
5. **Maintainability:** Permission logic reviewable in Git, testable as pure functions
|
||||
|
||||
### Core Concepts
|
||||
|
||||
**Permission Set:** Defines a collection of permissions (e.g., "read_only", "admin")
|
||||
|
||||
**Role:** A named job function that references one Permission Set (e.g., "Vorstand" uses "read_only")
|
||||
|
||||
**User:** Each user has exactly one Role, inheriting that Role's Permission Set
|
||||
|
||||
**Scope:** Defines the breadth of access - "own" (only own data), "linked" (data connected to user), "all" (everything)
|
||||
|
||||
---
|
||||
|
||||
## Evaluated Approaches
|
||||
|
||||
During the design phase, we evaluated multiple implementation approaches to find the optimal balance between simplicity, performance, and future extensibility.
|
||||
|
||||
### Approach 1: JSONB in Roles Table
|
||||
|
||||
Store all permissions as a single JSONB column directly in the roles table.
|
||||
|
||||
**Advantages:**
|
||||
- Simplest database schema (single table)
|
||||
- Very flexible structure
|
||||
- No additional tables needed
|
||||
- Fast to implement
|
||||
|
||||
**Disadvantages:**
|
||||
- Poor queryability (can't efficiently filter by specific permissions)
|
||||
- No referential integrity
|
||||
- Difficult to validate structure
|
||||
- Hard to audit permission changes
|
||||
- Can't leverage database indexes effectively
|
||||
|
||||
**Verdict:** Rejected - Poor queryability makes it unsuitable for complex permission logic.
|
||||
|
||||
---
|
||||
|
||||
### Approach 2: Normalized Database Tables
|
||||
|
||||
Separate tables for `permission_sets`, `permission_set_resources`, `permission_set_pages` with full normalization.
|
||||
|
||||
**Advantages:**
|
||||
- Fully queryable with SQL
|
||||
- Runtime configurable permissions
|
||||
- Strong referential integrity
|
||||
- Easy to audit changes
|
||||
- Can index for performance
|
||||
|
||||
**Disadvantages:**
|
||||
- Complex database schema (4+ tables)
|
||||
- DB queries required for every permission check
|
||||
- Requires ETS cache for performance
|
||||
- Needs admin UI for permission management
|
||||
- Longer implementation time (4-5 weeks)
|
||||
- Overkill for fixed set of 4 permission sets
|
||||
|
||||
**Verdict:** Deferred to Phase 3 - Excellent for runtime configuration but too complex for MVP.
|
||||
|
||||
---
|
||||
|
||||
### Approach 3: Custom Authorizer
|
||||
|
||||
Implement a custom Ash Authorizer from scratch instead of using Ash Policies.
|
||||
|
||||
**Advantages:**
|
||||
- Complete control over authorization logic
|
||||
- Can implement any custom behavior
|
||||
- Not constrained by Ash Policy DSL
|
||||
|
||||
**Disadvantages:**
|
||||
- Significantly more code to write and maintain
|
||||
- Loses benefits of Ash's declarative policies
|
||||
- Harder to test than built-in policy system
|
||||
- Mixes declarative and imperative approaches
|
||||
- Must reimplement filter generation for queries
|
||||
- Higher bug risk
|
||||
|
||||
**Verdict:** Rejected - Too much custom code, reduces maintainability and loses Ash ecosystem benefits.
|
||||
|
||||
---
|
||||
|
||||
### Approach 4: Simple Role Enum
|
||||
|
||||
Add a simple `:role` enum field directly on User resource with hardcoded checks in each policy.
|
||||
|
||||
**Advantages:**
|
||||
- Very simple to implement (< 1 week)
|
||||
- No extra tables needed
|
||||
- Fast performance
|
||||
- Easy to understand
|
||||
|
||||
**Disadvantages:**
|
||||
- No separation between roles and permissions
|
||||
- Can't add new roles without code changes
|
||||
- No dynamic permission configuration
|
||||
- Not extensible to field-level permissions
|
||||
- Violates separation of concerns (role = job function, not permission set)
|
||||
- Difficult to maintain as requirements grow
|
||||
|
||||
**Verdict:** Rejected - Too inflexible, doesn't meet requirement for configurable permissions and role separation.
|
||||
|
||||
---
|
||||
|
||||
### Approach 5: Hardcoded Permissions with Migration Path (SELECTED for MVP)
|
||||
|
||||
Permission Sets hardcoded in Elixir module, only Roles table in database.
|
||||
|
||||
**Advantages:**
|
||||
- Fast implementation (2-3 weeks vs 4-5 weeks)
|
||||
- Maximum performance (zero DB queries, < 1 microsecond)
|
||||
- Simple to test (pure functions)
|
||||
- Code-reviewable permissions (visible in Git)
|
||||
- No migration needed for existing data
|
||||
- Clearly defined 4 permission sets as required
|
||||
- Clear migration path to database-backed solution (Phase 3)
|
||||
- Maintains separation of roles and permission sets
|
||||
|
||||
**Disadvantages:**
|
||||
- Permissions not editable at runtime (only role assignment possible)
|
||||
- New permissions require code deployment
|
||||
- Not suitable if permissions change frequently (> 1x/week)
|
||||
- Limited to the 4 predefined permission sets
|
||||
|
||||
**Why Selected:**
|
||||
- MVP requirement is for 4 fixed permission sets (not custom ones)
|
||||
- No stated requirement for runtime permission editing
|
||||
- Performance is critical for authorization checks
|
||||
- Fast time-to-market (2-3 weeks)
|
||||
- Clear upgrade path when runtime configuration becomes necessary
|
||||
|
||||
**Migration Path:**
|
||||
When runtime permission editing becomes a business requirement, migrate to Approach 2 (normalized DB tables) without changing the public API of the PermissionSets module.
|
||||
|
||||
---
|
||||
|
||||
## Requirements Summary
|
||||
|
||||
### Four Predefined Permission Sets
|
||||
|
||||
1. **own_data** - Access only to own user account and linked member profile
|
||||
2. **read_only** - Read access to all members and custom fields
|
||||
3. **normal_user** - Create/Read/Update members and full CRUD on custom fields (no member deletion for safety)
|
||||
4. **admin** - Unrestricted access to all resources including user management
|
||||
|
||||
### Example Roles
|
||||
|
||||
- **Mitglied (Member)** - Uses "own_data" permission set, default role
|
||||
- **Vorstand (Board)** - Uses "read_only" permission set
|
||||
- **Kassenwart (Treasurer)** - Uses "normal_user" permission set
|
||||
- **Buchhaltung (Accounting)** - Uses "read_only" permission set
|
||||
- **Admin** - Uses "admin" permission set
|
||||
|
||||
### Authorization Levels
|
||||
|
||||
**Resource Level (MVP):**
|
||||
- Controls create, read, update, destroy actions on resources
|
||||
- Resources: Member, User, Property, PropertyType, Role
|
||||
|
||||
**Page Level (MVP):**
|
||||
- Controls access to LiveView pages
|
||||
- Example: "/members/new" requires Member.create permission
|
||||
|
||||
**Field Level (Phase 2 - Future):**
|
||||
- Controls read/write access to specific fields
|
||||
- Example: Only Treasurer can see payment_history field
|
||||
|
||||
### Special Cases
|
||||
|
||||
1. **Own Credentials:** Users can always edit their own email and password
|
||||
2. **Linked Member Email:** Only admins can edit email of members linked to users
|
||||
3. **User-Member Linking:** Only admins can link/unlink users to members (except self-service creation)
|
||||
|
||||
---
|
||||
|
||||
## Selected Architecture
|
||||
|
||||
### Conceptual Model
|
||||
|
||||
```
|
||||
Elixir Module: PermissionSets
|
||||
↓ (defines)
|
||||
Permission Set (:own_data, :read_only, :normal_user, :admin)
|
||||
↓ (referenced by)
|
||||
Role (stored in DB: "Vorstand" → "read_only")
|
||||
↓ (assigned to)
|
||||
User (each user has one role_id)
|
||||
```
|
||||
|
||||
### Database Schema (MVP)
|
||||
|
||||
**Single Table: roles**
|
||||
|
||||
Contains:
|
||||
- id (UUID)
|
||||
- name (e.g., "Vorstand")
|
||||
- description
|
||||
- permission_set_name (String: "own_data", "read_only", "normal_user", "admin")
|
||||
- is_system_role (boolean, protects critical roles)
|
||||
|
||||
**No Permission Tables:** Permission Sets are hardcoded in Elixir module.
|
||||
|
||||
### Why This Approach?
|
||||
|
||||
**Fast Implementation:** 2-3 weeks instead of 4-5 weeks
|
||||
|
||||
**Maximum Performance:**
|
||||
- Zero database queries for permission checks
|
||||
- Pure function calls (< 1 microsecond)
|
||||
- No caching needed
|
||||
|
||||
**Code Review:**
|
||||
- Permissions visible in Git diffs
|
||||
- Easy to review changes
|
||||
- No accidental runtime modifications
|
||||
|
||||
**Clear Upgrade Path:**
|
||||
- Phase 1 (MVP): Hardcoded
|
||||
- Phase 2: Add field-level permissions
|
||||
- Phase 3: Migrate to database-backed with admin UI
|
||||
|
||||
**Meets Requirements:**
|
||||
- Four predefined permission sets ✓
|
||||
- Dynamic role creation ✓ (Roles in DB)
|
||||
- Role-to-user assignment ✓
|
||||
- No requirement for runtime permission changes stated
|
||||
|
||||
---
|
||||
|
||||
## Permission System Design
|
||||
|
||||
### Permission Structure
|
||||
|
||||
Each Permission Set contains:
|
||||
|
||||
**Resources:** List of resource permissions
|
||||
- resource: "Member", "User", "Property", etc.
|
||||
- action: :read, :create, :update, :destroy
|
||||
- scope: :own, :linked, :all
|
||||
- granted: true/false
|
||||
|
||||
**Pages:** List of accessible page paths
|
||||
- Examples: "/", "/members", "/members/:id/edit"
|
||||
- "*" for admin (all pages)
|
||||
|
||||
### Scope Definitions
|
||||
|
||||
**:own** - Only records where id == actor.id
|
||||
- Example: User can read their own User record
|
||||
|
||||
**:linked** - Only records where user_id == actor.id
|
||||
- Example: User can read Member linked to their account
|
||||
|
||||
**:all** - All records without restriction
|
||||
- Example: Admin can read all Members
|
||||
|
||||
### How Authorization Works
|
||||
|
||||
1. User attempts action on resource (e.g., read Member)
|
||||
2. System loads user's role from database
|
||||
3. Role contains permission_set_name string
|
||||
4. PermissionSets module returns permissions for that set
|
||||
5. Custom Policy Check evaluates permissions against action
|
||||
6. Access granted or denied based on scope
|
||||
|
||||
### Custom Policy Check
|
||||
|
||||
A reusable Ash Policy Check that:
|
||||
- Reads user's permission_set_name from their role
|
||||
- Calls PermissionSets.get_permissions/1
|
||||
- Matches resource + action against permissions list
|
||||
- Applies scope filters (own/linked/all)
|
||||
- Returns authorized, forbidden, or filtered query
|
||||
|
||||
---
|
||||
|
||||
## User-Member Linking Strategy
|
||||
|
||||
### Problem Statement
|
||||
|
||||
Users need to create member profiles for themselves (self-service), but only admins should be able to:
|
||||
- Link existing members to users
|
||||
- Unlink members from users
|
||||
- Create members pre-linked to arbitrary users
|
||||
|
||||
### Selected Approach: Separate Ash Actions
|
||||
|
||||
Instead of complex field-level validation, we use action-based authorization.
|
||||
|
||||
### Actions on Member Resource
|
||||
|
||||
**1. create_member_for_self** (All authenticated users)
|
||||
- Automatically sets user_id = actor.id
|
||||
- User cannot specify different user_id
|
||||
- UI: "Create My Profile" button
|
||||
|
||||
**2. create_member** (Admin only)
|
||||
- Can set user_id to any user or leave unlinked
|
||||
- Full flexibility for admin
|
||||
- UI: Admin member management form
|
||||
|
||||
**3. link_member_to_user** (Admin only)
|
||||
- Updates existing member to set user_id
|
||||
- Connects unlinked member to user account
|
||||
|
||||
**4. unlink_member_from_user** (Admin only)
|
||||
- Sets user_id to nil
|
||||
- Disconnects member from user account
|
||||
|
||||
**5. update** (Permission-based)
|
||||
- Normal updates (name, address, etc.)
|
||||
- user_id NOT in accept list (prevents manipulation)
|
||||
- Available to users with Member.update permission
|
||||
|
||||
### Why Separate Actions?
|
||||
|
||||
**Explicit Semantics:** Each action has clear, single purpose
|
||||
|
||||
**Server-Side Security:** user_id set by server, not client input
|
||||
|
||||
**Better UX:** Different UI flows for different use cases
|
||||
|
||||
**Simple Policies:** Authorization at action level, not field level
|
||||
|
||||
**Easy Testing:** Each action independently testable
|
||||
|
||||
---
|
||||
|
||||
## Field-Level Permissions Strategy
|
||||
|
||||
### Status: Phase 2 (Future Implementation)
|
||||
|
||||
Field-level permissions are NOT implemented in MVP but have a clear strategy defined.
|
||||
|
||||
### Problem Statement
|
||||
|
||||
Some scenarios require field-level control:
|
||||
- **Read restrictions:** Hide payment_history from certain roles
|
||||
- **Write restrictions:** Only treasurer can edit payment fields
|
||||
- **Complexity:** Ash Policies work at resource level, not field level
|
||||
|
||||
### Selected Strategy
|
||||
|
||||
**For Read Restrictions:**
|
||||
Use Ash Calculations or Custom Preparations
|
||||
- Calculations: Dynamically compute field based on permissions
|
||||
- Preparations: Filter select to only allowed fields
|
||||
- Field returns nil or "[Hidden]" if unauthorized
|
||||
|
||||
**For Write Restrictions:**
|
||||
Use Custom Validations
|
||||
- Validate changeset against field permissions
|
||||
- Similar to existing linked-member email validation
|
||||
- Return error if field modification not allowed
|
||||
|
||||
### Why This Strategy?
|
||||
|
||||
**Leverages Ash Features:** Uses built-in mechanisms, not custom authorizer
|
||||
|
||||
**Performance:** Calculations are lazy, Preparations run once per query
|
||||
|
||||
**Maintainable:** Clear validation logic, standard Ash patterns
|
||||
|
||||
**Extensible:** Easy to add new field restrictions
|
||||
|
||||
### Implementation Timeline
|
||||
|
||||
**Phase 1 (MVP):** No field-level permissions
|
||||
|
||||
**Phase 2:** Extend PermissionSets to include field permissions, implement Calculations/Validations
|
||||
|
||||
**Phase 3:** If migrating to database, add permission_set_fields table
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: MVP with Hardcoded Permissions (2-3 weeks)
|
||||
|
||||
**What's Included:**
|
||||
- Roles table in database
|
||||
- PermissionSets Elixir module with 4 predefined sets
|
||||
- Custom Policy Check reading from module
|
||||
- UI Authorization Helpers for LiveView
|
||||
- Admin UI for role management (create, assign, delete roles)
|
||||
|
||||
**Limitations:**
|
||||
- Permissions not editable at runtime
|
||||
- New permissions require code deployment
|
||||
- Only 4 permission sets available
|
||||
|
||||
**Benefits:**
|
||||
- Fast implementation
|
||||
- Maximum performance
|
||||
- Simple testing and review
|
||||
|
||||
### Phase 2: Field-Level Permissions (Future, 2-3 weeks)
|
||||
|
||||
**When Needed:** Business requires field-level restrictions
|
||||
|
||||
**Implementation:**
|
||||
- Extend PermissionSets module with :fields key
|
||||
- Add Ash Calculations for read restrictions
|
||||
- Add custom validations for write restrictions
|
||||
- Update UI Helpers
|
||||
|
||||
**Migration:** No database changes, pure code additions
|
||||
|
||||
### Phase 3: Database-Backed Permissions (Future, 3-4 weeks)
|
||||
|
||||
**When Needed:** Runtime permission configuration required
|
||||
|
||||
**Implementation:**
|
||||
- Create permission tables in database
|
||||
- Seed script to migrate hardcoded permissions
|
||||
- Update PermissionSets module to query database
|
||||
- Add ETS cache for performance
|
||||
- Build admin UI for permission management
|
||||
|
||||
**Migration:** Seamless, no changes to existing Policies or UI code
|
||||
|
||||
### Decision Matrix: When to Migrate?
|
||||
|
||||
| Scenario | Recommended Phase |
|
||||
|----------|-------------------|
|
||||
| MVP with 4 fixed permission sets | Phase 1 |
|
||||
| Need field-level restrictions | Phase 2 |
|
||||
| Permission changes < 1x/month | Stay Phase 1 |
|
||||
| Need runtime permission config | Phase 3 |
|
||||
| Custom permission sets needed | Phase 3 |
|
||||
| Permission changes > 1x/week | Phase 3 |
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
**This Document (Overview):** High-level concepts, no code examples
|
||||
|
||||
**[roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md):** Complete technical specification with code examples
|
||||
|
||||
**[roles-and-permissions-implementation-plan.md](./roles-and-permissions-implementation-plan.md):** Detailed implementation plan with TDD approach
|
||||
|
||||
**[CODE_GUIDELINES.md](../CODE_GUIDELINES.md):** Project coding standards
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The selected architecture uses **hardcoded Permission Sets in Elixir** for the MVP, providing:
|
||||
- **Speed:** 2-3 weeks implementation vs 4-5 weeks
|
||||
- **Performance:** Zero database queries for authorization
|
||||
- **Clarity:** Permissions in Git, reviewable and testable
|
||||
- **Flexibility:** Clear migration path to database-backed system
|
||||
|
||||
**User-Member linking** uses **separate Ash Actions** for clarity and security.
|
||||
|
||||
**Field-level permissions** have a **defined strategy** (Calculations + Validations) for Phase 2 implementation.
|
||||
|
||||
The approach balances pragmatism for MVP delivery with extensibility for future requirements.
|
||||
|
||||
|
|
@ -66,14 +66,17 @@ defmodule Mv.Accounts.User do
|
|||
end
|
||||
|
||||
actions do
|
||||
# Default actions kept for framework/tooling integration:
|
||||
# - :create -> Used by AshAdmin's generated "Create" UI and by generic
|
||||
# AshPhoenix helpers that assume a default create action.
|
||||
# It does NOT manage the :member relationship. For admin
|
||||
# flows that may link an existing member, use :create_user.
|
||||
# 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.
|
||||
defaults [:read, :create, :destroy]
|
||||
#
|
||||
# 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:
|
||||
# - :create_user (for manual user creation with optional member link)
|
||||
# - :register_with_password (for password-based registration)
|
||||
# - :register_with_rauthy (for OIDC-based registration)
|
||||
defaults [:read, :destroy]
|
||||
|
||||
# Primary generic update action:
|
||||
# - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix
|
||||
|
|
@ -89,6 +92,12 @@ defmodule Mv.Accounts.User do
|
|||
# cannot be executed atomically. These validations need to query the database and perform
|
||||
# complex checks that are not supported in atomic operations.
|
||||
require_atomic? false
|
||||
|
||||
# Sync email changes to linked member (User → Member)
|
||||
# Only runs when email is being changed
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where [changing(:email)]
|
||||
end
|
||||
end
|
||||
|
||||
create :create_user do
|
||||
|
|
@ -111,6 +120,9 @@ defmodule Mv.Accounts.User do
|
|||
# If no member provided, that's fine (optional relationship)
|
||||
on_missing: :ignore
|
||||
)
|
||||
|
||||
# Sync user email to member when linking (User → Member)
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
||||
end
|
||||
|
||||
update :update_user do
|
||||
|
|
@ -137,6 +149,12 @@ defmodule Mv.Accounts.User do
|
|||
# If no member provided, remove existing relationship (allows member removal)
|
||||
on_missing: :unrelate
|
||||
)
|
||||
|
||||
# Sync email changes and handle linking (User → Member)
|
||||
# Runs when email OR member relationship changes
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where any([changing(:email), changing(:member)])
|
||||
end
|
||||
end
|
||||
|
||||
# Admin action for direct password changes in admin panel
|
||||
|
|
@ -153,6 +171,41 @@ defmodule Mv.Accounts.User do
|
|||
change AshAuthentication.Strategy.Password.HashPasswordChange
|
||||
end
|
||||
|
||||
# Action to link an OIDC account to an existing password-only user
|
||||
# This is called after the user has verified their password
|
||||
update :link_oidc_id do
|
||||
description "Links an OIDC ID to an existing user after password verification"
|
||||
accept []
|
||||
argument :oidc_id, :string, allow_nil?: false
|
||||
argument :oidc_user_info, :map, allow_nil?: false
|
||||
require_atomic? false
|
||||
|
||||
change fn changeset, _ctx ->
|
||||
oidc_id = Ash.Changeset.get_argument(changeset, :oidc_id)
|
||||
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")
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.change_attribute(:oidc_id, oidc_id)
|
||||
# Update email if it differs from OIDC provider
|
||||
# change_attribute/3 already checks if value matches existing value
|
||||
|> then(fn cs ->
|
||||
if new_email do
|
||||
Ash.Changeset.change_attribute(cs, :email, new_email)
|
||||
else
|
||||
cs
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# Sync email changes to member if email was updated
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where [changing(:email)]
|
||||
end
|
||||
end
|
||||
|
||||
read :get_by_subject do
|
||||
description "Get a user by the subject claim in a JWT"
|
||||
argument :subject, :string, allow_nil?: false
|
||||
|
|
@ -165,13 +218,18 @@ defmodule Mv.Accounts.User do
|
|||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
|
||||
|
||||
filter expr(email == get_path(^arg(:user_info), [:preferred_username]))
|
||||
# SECURITY: Filter by oidc_id, NOT by email!
|
||||
# This ensures that OIDC sign-in only works for users who have already
|
||||
# linked their account via OIDC. Password-only users (oidc_id = nil)
|
||||
# cannot be accessed via OIDC login without password verification.
|
||||
filter expr(oidc_id == get_path(^arg(:user_info), [:sub]))
|
||||
end
|
||||
|
||||
create :register_with_rauthy do
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
upsert? true
|
||||
# Upsert based on oidc_id (primary match for existing OIDC users)
|
||||
upsert_identity :unique_oidc_id
|
||||
|
||||
validate &__MODULE__.validate_oidc_id_present/2
|
||||
|
|
@ -185,6 +243,15 @@ defmodule Mv.Accounts.User do
|
|||
|> Ash.Changeset.change_attribute(:email, user_info["preferred_username"])
|
||||
|> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"])
|
||||
end
|
||||
|
||||
# Check for email collisions with existing accounts
|
||||
# This validation must run AFTER email and oidc_id are set above
|
||||
# - Raises PasswordVerificationRequired for password-protected OR passwordless users
|
||||
# - The LinkOidcAccountLive will auto-link passwordless users without password prompt
|
||||
validate Mv.Accounts.User.Validations.OidcEmailCollision
|
||||
|
||||
# Sync user email to member when linking (User → Member)
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -195,6 +262,10 @@ defmodule Mv.Accounts.User do
|
|||
where: [action_is([:register_with_password, :admin_set_password])],
|
||||
message: "must have length of at least 8"
|
||||
|
||||
# Email uniqueness check for all actions that change the email attribute
|
||||
# Validates that user email is not already used by another (unlinked) member
|
||||
validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember
|
||||
|
||||
# Email validation with EctoCommons.EmailValidator (same as Member)
|
||||
# This ensures consistency between User and Member email validation
|
||||
validate fn changeset, _ ->
|
||||
|
|
@ -255,6 +326,13 @@ defmodule Mv.Accounts.User do
|
|||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
# IMPORTANT: Email Synchronization
|
||||
# When user and member are linked, emails are automatically synced bidirectionally.
|
||||
# User.email is the source of truth - when a link is established, member.email
|
||||
# is overridden to match user.email. Subsequent changes to either email will
|
||||
# sync to the other resource.
|
||||
# See: Mv.EmailSync.Changes.SyncUserEmailToMember
|
||||
# Mv.EmailSync.Changes.SyncMemberEmailToUser
|
||||
attribute :email, :ci_string do
|
||||
allow_nil? false
|
||||
public? true
|
||||
|
|
|
|||
33
lib/accounts/user/errors/password_verification_required.ex
Normal file
33
lib/accounts/user/errors/password_verification_required.ex
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
defmodule Mv.Accounts.User.Errors.PasswordVerificationRequired do
|
||||
@moduledoc """
|
||||
Custom error raised when an OIDC login attempts to use an email that already exists
|
||||
in the system with a password-only account (no oidc_id set).
|
||||
|
||||
This error indicates that the user must verify their password before the OIDC account
|
||||
can be linked to the existing password account.
|
||||
"""
|
||||
use Splode.Error,
|
||||
fields: [:user_id, :oidc_user_info],
|
||||
class: :invalid
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
user_id: String.t(),
|
||||
oidc_user_info: map()
|
||||
}
|
||||
|
||||
@doc """
|
||||
Returns a human-readable error message.
|
||||
|
||||
## Parameters
|
||||
- error: The error struct containing user_id and oidc_user_info
|
||||
"""
|
||||
def message(%{user_id: user_id, oidc_user_info: user_info}) do
|
||||
email = Map.get(user_info, "preferred_username", "unknown")
|
||||
oidc_id = Map.get(user_info, "sub") || Map.get(user_info, "id", "unknown")
|
||||
|
||||
"""
|
||||
Password verification required: An account with email '#{email}' already exists (user_id: #{user_id}).
|
||||
To link your OIDC account (oidc_id: #{oidc_id}) to this existing account, please verify your password.
|
||||
"""
|
||||
end
|
||||
end
|
||||
172
lib/accounts/user/validations/oidc_email_collision.ex
Normal file
172
lib/accounts/user/validations/oidc_email_collision.ex
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
||||
@moduledoc """
|
||||
Validation that checks for email collisions during OIDC registration.
|
||||
|
||||
This validation prevents unauthorized account takeovers and enforces proper
|
||||
account linking flows based on user state.
|
||||
|
||||
## Scenarios:
|
||||
|
||||
1. **User exists with matching oidc_id**:
|
||||
- Allow (upsert will update the existing user)
|
||||
|
||||
2. **User exists with different oidc_id**:
|
||||
- Hard error: Cannot link multiple OIDC providers to same account
|
||||
- No linking possible - user must use original OIDC provider
|
||||
|
||||
3. **User exists without oidc_id** (password-protected OR passwordless):
|
||||
- Raise PasswordVerificationRequired error
|
||||
- User is redirected to LinkOidcAccountLive which will:
|
||||
- Show password form if user has password
|
||||
- Auto-link immediately if user is passwordless
|
||||
|
||||
4. **No user exists with this email**:
|
||||
- Allow (new user will be created)
|
||||
"""
|
||||
use Ash.Resource.Validation
|
||||
require Logger
|
||||
|
||||
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||
|
||||
@impl true
|
||||
def init(opts), do: {:ok, opts}
|
||||
|
||||
@impl true
|
||||
def validate(changeset, _opts, _context) do
|
||||
# Get the email and oidc_id from the changeset
|
||||
email = Ash.Changeset.get_attribute(changeset, :email)
|
||||
oidc_id = Ash.Changeset.get_attribute(changeset, :oidc_id)
|
||||
user_info = Ash.Changeset.get_argument(changeset, :user_info)
|
||||
|
||||
# Only validate if we have both email and oidc_id (from OIDC registration)
|
||||
if email && oidc_id && user_info do
|
||||
# Check if a user with this oidc_id already exists
|
||||
# If yes, this will be an upsert (email update), not a new registration
|
||||
existing_oidc_user =
|
||||
case Mv.Accounts.User
|
||||
|> Ash.Query.filter(oidc_id == ^to_string(oidc_id))
|
||||
|> Ash.read_one() do
|
||||
{:ok, user} -> user
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
check_email_collision(email, oidc_id, user_info, existing_oidc_user)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user) do
|
||||
# Find existing user with this email
|
||||
case Mv.Accounts.User
|
||||
|> Ash.Query.filter(email == ^to_string(email))
|
||||
|> Ash.read_one() do
|
||||
{:ok, nil} ->
|
||||
# No user exists with this email - OK to create new user
|
||||
:ok
|
||||
|
||||
{:ok, user_with_email} ->
|
||||
# User exists with this email - check if it's an upsert or registration
|
||||
is_upsert = not is_nil(existing_oidc_user)
|
||||
|
||||
if is_upsert do
|
||||
handle_upsert_scenario(user_with_email, user_info, existing_oidc_user)
|
||||
else
|
||||
handle_create_scenario(user_with_email, new_oidc_id, user_info)
|
||||
end
|
||||
|
||||
{:error, error} ->
|
||||
# Database error - log for debugging but don't expose internals to user
|
||||
Logger.error("Email uniqueness check failed during OIDC registration: #{inspect(error)}")
|
||||
{:error, field: :email, message: "Could not verify email uniqueness. Please try again."}
|
||||
end
|
||||
end
|
||||
|
||||
# Handle email update for existing OIDC user
|
||||
defp handle_upsert_scenario(user_with_email, user_info, existing_oidc_user) do
|
||||
cond do
|
||||
# Same user updating their own record
|
||||
not is_nil(existing_oidc_user) and user_with_email.id == existing_oidc_user.id ->
|
||||
:ok
|
||||
|
||||
# Different user exists with target email
|
||||
not is_nil(existing_oidc_user) and user_with_email.id != existing_oidc_user.id ->
|
||||
handle_email_conflict(user_with_email, user_info)
|
||||
|
||||
# Should not reach here
|
||||
true ->
|
||||
{:error, field: :email, message: "Unexpected error during email update"}
|
||||
end
|
||||
end
|
||||
|
||||
# Handle email conflict during upsert
|
||||
defp handle_email_conflict(user_with_email, user_info) do
|
||||
email = Map.get(user_info, "preferred_username", "unknown")
|
||||
email_user_oidc_id = user_with_email.oidc_id
|
||||
|
||||
# Check if target email belongs to another OIDC user
|
||||
if not is_nil(email_user_oidc_id) and email_user_oidc_id != "" do
|
||||
different_oidc_error(email)
|
||||
else
|
||||
email_taken_error(email)
|
||||
end
|
||||
end
|
||||
|
||||
# Handle new OIDC user registration scenarios
|
||||
defp handle_create_scenario(user_with_email, new_oidc_id, user_info) do
|
||||
email_user_oidc_id = user_with_email.oidc_id
|
||||
|
||||
cond do
|
||||
# Same oidc_id (should not happen in practice, but allow for safety)
|
||||
email_user_oidc_id == new_oidc_id ->
|
||||
:ok
|
||||
|
||||
# Different oidc_id exists (hard error)
|
||||
not is_nil(email_user_oidc_id) and email_user_oidc_id != "" and
|
||||
email_user_oidc_id != new_oidc_id ->
|
||||
email = Map.get(user_info, "preferred_username", "unknown")
|
||||
different_oidc_error(email)
|
||||
|
||||
# No oidc_id (require account linking)
|
||||
is_nil(email_user_oidc_id) or email_user_oidc_id == "" ->
|
||||
{:error,
|
||||
PasswordVerificationRequired.exception(
|
||||
user_id: user_with_email.id,
|
||||
oidc_user_info: user_info
|
||||
)}
|
||||
|
||||
# Should not reach here
|
||||
true ->
|
||||
{:error, field: :email, message: "Unexpected error during OIDC registration"}
|
||||
end
|
||||
end
|
||||
|
||||
# Generate error for different OIDC account conflict
|
||||
defp different_oidc_error(email) do
|
||||
{:error,
|
||||
field: :email,
|
||||
message:
|
||||
"Email '#{email}' is already linked to a different OIDC account. " <>
|
||||
"Cannot link multiple OIDC providers to the same account."}
|
||||
end
|
||||
|
||||
# Generate error for email already taken
|
||||
defp email_taken_error(email) do
|
||||
{:error,
|
||||
field: :email,
|
||||
message:
|
||||
"Cannot update email to '#{email}': This email is already registered to another account. " <>
|
||||
"Please change your email in the identity provider."}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def atomic?(), do: false
|
||||
|
||||
@impl true
|
||||
def describe(_opts) do
|
||||
[
|
||||
message: "OIDC email collision detected",
|
||||
vars: []
|
||||
]
|
||||
end
|
||||
end
|
||||
150
lib/membership/custom_field.ex
Normal file
150
lib/membership/custom_field.ex
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
defmodule Mv.Membership.CustomField do
|
||||
@moduledoc """
|
||||
Ash resource defining the schema for custom member fields.
|
||||
|
||||
## Overview
|
||||
CustomFields define the "schema" for custom fields in the membership system.
|
||||
Each CustomField specifies the name, data type, and behavior of a custom field
|
||||
that can be attached to members via CustomFieldValue resources.
|
||||
|
||||
## Attributes
|
||||
- `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday")
|
||||
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
|
||||
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
|
||||
- `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)
|
||||
- `:integer` - Numeric data (64-bit integers)
|
||||
- `:boolean` - True/false flags
|
||||
- `:date` - Date values (no time component)
|
||||
- `:email` - Validated email addresses (max 254 characters)
|
||||
|
||||
## Relationships
|
||||
- `has_many :custom_field_values` - All custom field values of this type
|
||||
|
||||
## Constraints
|
||||
- Name must be unique across all custom fields
|
||||
- Name maximum length: 100 characters
|
||||
- Deleting a custom field will cascade delete all associated custom field values
|
||||
|
||||
## Calculations
|
||||
- `assigned_members_count` - Returns the number of distinct members with values for this custom field
|
||||
|
||||
## Examples
|
||||
# Create a new custom field
|
||||
CustomField.create!(%{
|
||||
name: "phone_mobile",
|
||||
value_type: :string,
|
||||
description: "Mobile phone number"
|
||||
})
|
||||
|
||||
# Create a required custom field
|
||||
CustomField.create!(%{
|
||||
name: "emergency_contact",
|
||||
value_type: :string,
|
||||
required: true
|
||||
})
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "custom_fields"
|
||||
repo Mv.Repo
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read, :update]
|
||||
default_accept [:name, :value_type, :description, :immutable, :required, :show_in_overview]
|
||||
|
||||
create :create do
|
||||
accept [:name, :value_type, :description, :immutable, :required, :show_in_overview]
|
||||
change Mv.Membership.CustomField.Changes.GenerateSlug
|
||||
validate string_length(:slug, min: 1)
|
||||
end
|
||||
|
||||
destroy :destroy_with_values do
|
||||
primary? true
|
||||
end
|
||||
|
||||
read :prepare_deletion do
|
||||
argument :id, :uuid, allow_nil?: false
|
||||
|
||||
filter expr(id == ^arg(:id))
|
||||
prepare build(load: [:assigned_members_count])
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
attribute :name, :string,
|
||||
allow_nil?: false,
|
||||
public?: true,
|
||||
constraints: [
|
||||
max_length: 100,
|
||||
trim?: true
|
||||
]
|
||||
|
||||
attribute :slug, :string,
|
||||
allow_nil?: false,
|
||||
public?: true,
|
||||
writable?: false,
|
||||
constraints: [
|
||||
max_length: 100,
|
||||
trim?: true
|
||||
]
|
||||
|
||||
attribute :value_type, :atom,
|
||||
constraints: [one_of: [:string, :integer, :boolean, :date, :email]],
|
||||
allow_nil?: false,
|
||||
description: "Defines the datatype `CustomFieldValue.value` is interpreted as"
|
||||
|
||||
attribute :description, :string,
|
||||
allow_nil?: true,
|
||||
public?: true,
|
||||
constraints: [
|
||||
max_length: 500,
|
||||
trim?: true
|
||||
]
|
||||
|
||||
attribute :immutable, :boolean,
|
||||
default: false,
|
||||
allow_nil?: false
|
||||
|
||||
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
|
||||
has_many :custom_field_values, Mv.Membership.CustomFieldValue
|
||||
end
|
||||
|
||||
calculations do
|
||||
calculate :assigned_members_count,
|
||||
:integer,
|
||||
expr(
|
||||
fragment(
|
||||
"(SELECT COUNT(DISTINCT member_id) FROM custom_field_values WHERE custom_field_id = ?)",
|
||||
id
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_name, [:name]
|
||||
identity :unique_slug, [:slug]
|
||||
end
|
||||
end
|
||||
118
lib/membership/custom_field/changes/generate_slug.ex
Normal file
118
lib/membership/custom_field/changes/generate_slug.ex
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
defmodule Mv.Membership.CustomField.Changes.GenerateSlug do
|
||||
@moduledoc """
|
||||
Ash Change that automatically generates a URL-friendly slug from the `name` attribute.
|
||||
|
||||
## Behavior
|
||||
|
||||
- **On Create**: Generates a slug from the name attribute using slugify
|
||||
- **On Update**: Slug remains unchanged (immutable after creation)
|
||||
- **Slug Generation**: Uses the `slugify` library to convert name to slug
|
||||
- Converts to lowercase
|
||||
- Replaces spaces with hyphens
|
||||
- Removes special characters
|
||||
- Handles UTF-8 characters (e.g., ä → a, ß → ss)
|
||||
- Trims leading/trailing hyphens
|
||||
- Truncates to max 100 characters
|
||||
|
||||
## Examples
|
||||
|
||||
# Create with automatic slug generation
|
||||
CustomField.create!(%{name: "Mobile Phone"})
|
||||
# => %CustomField{name: "Mobile Phone", slug: "mobile-phone"}
|
||||
|
||||
# German umlauts are converted
|
||||
CustomField.create!(%{name: "Café Müller"})
|
||||
# => %CustomField{name: "Café Müller", slug: "cafe-muller"}
|
||||
|
||||
# Slug is immutable on update
|
||||
custom_field = CustomField.create!(%{name: "Original"})
|
||||
CustomField.update!(custom_field, %{name: "New Name"})
|
||||
# => %CustomField{name: "New Name", slug: "original"} # slug unchanged!
|
||||
|
||||
## Implementation Note
|
||||
|
||||
This change only runs on `:create` actions. The slug is immutable by design,
|
||||
as changing slugs would break external references (e.g., CSV imports/exports).
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
@doc """
|
||||
Generates a slug from the changeset's `name` attribute.
|
||||
|
||||
Only runs on create actions. Returns the changeset unchanged if:
|
||||
- The action is not :create
|
||||
- The name is not being changed
|
||||
- The name is nil or empty
|
||||
|
||||
## Parameters
|
||||
|
||||
- `changeset` - The Ash changeset
|
||||
|
||||
## Returns
|
||||
|
||||
The changeset with the `:slug` attribute set to the generated slug.
|
||||
"""
|
||||
def change(changeset, _opts, _context) do
|
||||
# Only generate slug on create, not on update (immutability)
|
||||
if changeset.action_type == :create do
|
||||
case Ash.Changeset.get_attribute(changeset, :name) do
|
||||
nil ->
|
||||
changeset
|
||||
|
||||
name when is_binary(name) ->
|
||||
slug = generate_slug(name)
|
||||
Ash.Changeset.force_change_attribute(changeset, :slug, slug)
|
||||
end
|
||||
else
|
||||
# On update, don't touch the slug (immutable)
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a URL-friendly slug from a given string.
|
||||
|
||||
Uses the `slugify` library to create a clean, lowercase slug with:
|
||||
- Spaces replaced by hyphens
|
||||
- Special characters removed
|
||||
- UTF-8 characters transliterated (ä → a, ß → ss, etc.)
|
||||
- Multiple consecutive hyphens reduced to single hyphen
|
||||
- Leading/trailing hyphens removed
|
||||
- Maximum length of 100 characters
|
||||
|
||||
## Examples
|
||||
|
||||
iex> generate_slug("Mobile Phone")
|
||||
"mobile-phone"
|
||||
|
||||
iex> generate_slug("Café Müller")
|
||||
"cafe-muller"
|
||||
|
||||
iex> generate_slug("TEST NAME")
|
||||
"test-name"
|
||||
|
||||
iex> generate_slug("E-Mail & Address!")
|
||||
"e-mail-address"
|
||||
|
||||
iex> generate_slug("Multiple Spaces")
|
||||
"multiple-spaces"
|
||||
|
||||
iex> generate_slug("-Test-")
|
||||
"test"
|
||||
|
||||
iex> generate_slug("Straße")
|
||||
"strasse"
|
||||
|
||||
"""
|
||||
def generate_slug(name) when is_binary(name) do
|
||||
slug = Slug.slugify(name)
|
||||
|
||||
case slug do
|
||||
nil -> ""
|
||||
"" -> ""
|
||||
slug when is_binary(slug) -> String.slice(slug, 0, 100)
|
||||
end
|
||||
end
|
||||
|
||||
def generate_slug(_), do: ""
|
||||
end
|
||||
110
lib/membership/custom_field_value.ex
Normal file
110
lib/membership/custom_field_value.ex
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
defmodule Mv.Membership.CustomFieldValue do
|
||||
@moduledoc """
|
||||
Ash resource representing a custom field value for a member.
|
||||
|
||||
## Overview
|
||||
CustomFieldValues implement the Entity-Attribute-Value (EAV) pattern, allowing
|
||||
dynamic custom fields to be attached to members. Each custom field value links a
|
||||
member to a custom field and stores the actual value.
|
||||
|
||||
## Value Storage
|
||||
Values are stored using Ash's union type with JSONB storage format:
|
||||
```json
|
||||
{
|
||||
"type": "string",
|
||||
"value": "example"
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Types
|
||||
- `:string` - Text data
|
||||
- `:integer` - Numeric data
|
||||
- `:boolean` - True/false flags
|
||||
- `:date` - Date values
|
||||
- `:email` - Validated email addresses (custom type)
|
||||
|
||||
## Relationships
|
||||
- `belongs_to :member` - The member this custom field value belongs to (CASCADE delete)
|
||||
- `belongs_to :custom_field` - The custom field definition (CASCADE delete)
|
||||
|
||||
## Constraints
|
||||
- Each member can have only one custom field value per custom field (unique composite index)
|
||||
- Custom field values are deleted when the associated member is deleted (CASCADE)
|
||||
- Custom field values are deleted when the associated custom field is deleted (CASCADE)
|
||||
- String values maximum length: 10,000 characters
|
||||
- Email values maximum length: 254 characters (RFC 5321)
|
||||
|
||||
## Future Features
|
||||
- Type-matching validation (value type must match custom field's value_type) - to be implemented
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "custom_field_values"
|
||||
repo Mv.Repo
|
||||
|
||||
references do
|
||||
reference :member, on_delete: :delete
|
||||
reference :custom_field, on_delete: :delete
|
||||
end
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
default_accept [:value, :member_id, :custom_field_id]
|
||||
|
||||
read :by_custom_field_id do
|
||||
argument :custom_field_id, :uuid, allow_nil?: false
|
||||
|
||||
filter expr(custom_field_id == ^arg(:custom_field_id))
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
attribute :value, :union,
|
||||
constraints: [
|
||||
storage: :type_and_value,
|
||||
types: [
|
||||
boolean: [
|
||||
type: :boolean
|
||||
],
|
||||
date: [
|
||||
type: :date
|
||||
],
|
||||
integer: [
|
||||
type: :integer
|
||||
],
|
||||
string: [
|
||||
type: :string,
|
||||
constraints: [
|
||||
max_length: 10_000,
|
||||
trim?: true
|
||||
]
|
||||
],
|
||||
email: [
|
||||
type: Mv.Membership.Email
|
||||
]
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :member, Mv.Membership.Member
|
||||
|
||||
belongs_to :custom_field, Mv.Membership.CustomField
|
||||
end
|
||||
|
||||
calculations do
|
||||
calculate :value_to_string, :string, expr(value[:value] <> "")
|
||||
end
|
||||
|
||||
# Ensure a member can only have one custom field value per custom field
|
||||
# For example: A member can have only one "phone" custom field value, one "email" custom field value, etc.
|
||||
identities do
|
||||
identity :unique_custom_field_per_member, [:member_id, :custom_field_id]
|
||||
end
|
||||
end
|
||||
|
|
@ -1,4 +1,38 @@
|
|||
defmodule Mv.Membership.Email do
|
||||
@moduledoc """
|
||||
Custom Ash type for validated email addresses.
|
||||
|
||||
## Overview
|
||||
This type extends `:string` with email-specific validation constraints.
|
||||
It ensures that email values stored in CustomFieldValue resources are valid email
|
||||
addresses according to a standard regex pattern.
|
||||
|
||||
## Validation Rules
|
||||
- **Optional**: `nil` and empty strings are allowed (custom fields are optional)
|
||||
- Minimum length: 5 characters (for non-empty values)
|
||||
- Maximum length: 254 characters (RFC 5321 maximum)
|
||||
- Pattern: Standard email format (username@domain.tld)
|
||||
- Automatic trimming of leading/trailing whitespace (empty strings become `nil`)
|
||||
|
||||
## Usage
|
||||
This type is used in the CustomFieldValue union type for custom fields with
|
||||
`value_type: :email` in CustomField definitions.
|
||||
|
||||
## Example
|
||||
# In a custom field definition
|
||||
CustomField.create!(%{
|
||||
name: "work_email",
|
||||
value_type: :email
|
||||
})
|
||||
|
||||
# Valid values
|
||||
"user@example.com"
|
||||
"first.last@company.co.uk"
|
||||
|
||||
# Invalid values
|
||||
"not-an-email" # Missing @ and domain
|
||||
"a@b" # Too short
|
||||
"""
|
||||
@match_pattern ~S/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/
|
||||
@match_regex Regex.compile!(@match_pattern)
|
||||
@min_length 5
|
||||
|
|
@ -13,11 +47,18 @@ defmodule Mv.Membership.Email do
|
|||
max_length: @max_length
|
||||
]
|
||||
|
||||
@impl true
|
||||
def cast_input(nil, _), do: {:ok, nil}
|
||||
|
||||
@impl true
|
||||
def cast_input(value, _) when is_binary(value) do
|
||||
value = String.trim(value)
|
||||
|
||||
cond do
|
||||
# Empty string after trim becomes nil (optional field)
|
||||
value == "" ->
|
||||
{:ok, nil}
|
||||
|
||||
String.length(value) < @min_length ->
|
||||
:error
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,51 @@
|
|||
defmodule Mv.Membership.Member do
|
||||
@moduledoc """
|
||||
Ash resource representing a club member.
|
||||
|
||||
## Overview
|
||||
Members are the core entity in the membership management system. Each member
|
||||
can have:
|
||||
- Personal information (name, email, phone, address)
|
||||
- Optional link to a User account (1:1 relationship)
|
||||
- Dynamic custom field values via CustomField system
|
||||
- Full-text searchable profile
|
||||
|
||||
## Email Synchronization
|
||||
When a member is linked to a user account, emails are automatically synchronized
|
||||
bidirectionally. User.email is the source of truth on initial link.
|
||||
See `Mv.EmailSync` for details.
|
||||
|
||||
## Relationships
|
||||
- `has_many :custom_field_values` - Dynamic custom fields
|
||||
- `has_one :user` - Optional authentication account link
|
||||
|
||||
## Validations
|
||||
- Required: first_name, last_name, email
|
||||
- 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
|
||||
- Email uniqueness: prevents conflicts with unlinked users
|
||||
|
||||
## Full-Text Search
|
||||
Members have a `search_vector` attribute (tsvector) that is automatically
|
||||
updated via database trigger. Search includes name, email, notes, and contact fields.
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
# Module constants
|
||||
@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
|
||||
|
|
@ -13,29 +56,15 @@ defmodule Mv.Membership.Member do
|
|||
|
||||
create :create_member do
|
||||
primary? true
|
||||
# Properties can be created along with member
|
||||
argument :properties, {:array, :map}
|
||||
# Custom field values can be created along with member
|
||||
argument :custom_field_values, {:array, :map}
|
||||
# Allow user to be passed as argument for relationship management
|
||||
# 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(:properties, type: :create)
|
||||
change manage_relationship(:custom_field_values, type: :create)
|
||||
|
||||
# Manage the user relationship during member creation
|
||||
change manage_relationship(:user, :user,
|
||||
|
|
@ -48,35 +77,27 @@ defmodule Mv.Membership.Member do
|
|||
# If no user provided, that's fine (optional relationship)
|
||||
on_missing: :ignore
|
||||
)
|
||||
|
||||
# Sync user email to member when linking (User → Member)
|
||||
# Only runs when user relationship is being changed
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where [changing(:user)]
|
||||
end
|
||||
end
|
||||
|
||||
update :update_member do
|
||||
primary? true
|
||||
# Required because custom validation function cannot be done atomically
|
||||
require_atomic? false
|
||||
# Properties can be updated or created along with member
|
||||
argument :properties, {:array, :map}
|
||||
# Custom field values can be updated or created along with member
|
||||
argument :custom_field_values, {:array, :map}
|
||||
# Allow user to be passed as argument for relationship management
|
||||
# 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(:properties, on_match: :update, on_no_match: :create)
|
||||
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
|
||||
|
||||
# Manage the user relationship during member update
|
||||
change manage_relationship(:user, :user,
|
||||
|
|
@ -89,9 +110,134 @@ defmodule Mv.Membership.Member do
|
|||
# If no user provided, remove existing relationship (allows user removal)
|
||||
on_missing: :unrelate
|
||||
)
|
||||
|
||||
# Sync member email to user when email changes (Member → User)
|
||||
# Only runs when email is being changed
|
||||
change Mv.EmailSync.Changes.SyncMemberEmailToUser do
|
||||
where [changing(:email)]
|
||||
end
|
||||
|
||||
# Sync user email to member when linking (User → Member)
|
||||
# Only runs when user relationship is being changed
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where [changing(:user)]
|
||||
end
|
||||
end
|
||||
|
||||
# Action to handle fuzzy search on specific fields
|
||||
read :search do
|
||||
argument :query, :string, allow_nil?: true
|
||||
argument :similarity_threshold, :float, allow_nil?: true
|
||||
|
||||
prepare fn query, _ctx ->
|
||||
q = Ash.Query.get_argument(query, :query) || ""
|
||||
|
||||
# Use default similarity threshold if not provided
|
||||
# Lower value leads to more results but also more unspecific results
|
||||
threshold =
|
||||
Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold
|
||||
|
||||
if is_binary(q) and String.trim(q) != "" do
|
||||
q2 = String.trim(q)
|
||||
pat = "%" <> q2 <> "%"
|
||||
|
||||
# FTS as main filter and fuzzy search just for first name, last name and strees
|
||||
query
|
||||
|> Ash.Query.filter(
|
||||
expr(
|
||||
# Substring on numeric-like fields (best effort, supports middle substrings)
|
||||
fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^q2) or
|
||||
fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q2) or
|
||||
contains(postal_code, ^q2) or
|
||||
contains(house_number, ^q2) or
|
||||
contains(phone_number, ^q2) or
|
||||
contains(email, ^q2) or
|
||||
contains(city, ^q2) or ilike(city, ^pat) or
|
||||
fragment("? % first_name", ^q2) or
|
||||
fragment("? % last_name", ^q2) or
|
||||
fragment("? % street", ^q2) or
|
||||
fragment("word_similarity(?, first_name) > ?", ^q2, ^threshold) or
|
||||
fragment("word_similarity(?, last_name) > ?", ^q2, ^threshold) or
|
||||
fragment("word_similarity(?, street) > ?", ^q2, ^threshold) or
|
||||
fragment("similarity(first_name, ?) > ?", ^q2, ^threshold) or
|
||||
fragment("similarity(last_name, ?) > ?", ^q2, ^threshold) or
|
||||
fragment("similarity(street, ?) > ?", ^q2, ^threshold)
|
||||
)
|
||||
)
|
||||
else
|
||||
query
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Action to find members available for linking to a user account
|
||||
# Returns only unlinked members (user_id == nil), limited to 10 results
|
||||
#
|
||||
# Filtering behavior:
|
||||
# - If search_query provided: fuzzy search on names and email
|
||||
# - If no search_query: return all unlinked members (up to limit)
|
||||
# - user_email should be handled by caller with filter_by_email_match/2
|
||||
read :available_for_linking do
|
||||
argument :user_email, :string, allow_nil?: true
|
||||
argument :search_query, :string, allow_nil?: true
|
||||
|
||||
prepare fn query, _ctx ->
|
||||
user_email = Ash.Query.get_argument(query, :user_email)
|
||||
search_query = Ash.Query.get_argument(query, :search_query)
|
||||
|
||||
query
|
||||
|> Ash.Query.filter(is_nil(user))
|
||||
|> apply_linking_filters(user_email, search_query)
|
||||
|> Ash.Query.limit(@member_search_limit)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Filters members list based on email match priority.
|
||||
|
||||
Priority logic:
|
||||
1. If email matches a member: return ONLY that member (highest priority)
|
||||
2. If email doesn't match: return all members (for display in dropdown)
|
||||
|
||||
This is used with :available_for_linking action to implement email-priority behavior:
|
||||
- user_email matches → Only this member
|
||||
- user_email does NOT match + NO search_query → All unlinked members
|
||||
- user_email does NOT match + search_query provided → search_query filtered members
|
||||
|
||||
## Parameters
|
||||
- `members` - List of Member structs (from :available_for_linking action)
|
||||
- `user_email` - Email string to match against member emails
|
||||
|
||||
## Returns
|
||||
- List of Member structs (either single email match or all members)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> members = [%Member{email: "test@example.com"}, %Member{email: "other@example.com"}]
|
||||
iex> filter_by_email_match(members, "test@example.com")
|
||||
[%Member{email: "test@example.com"}]
|
||||
|
||||
iex> filter_by_email_match(members, "nomatch@example.com")
|
||||
[%Member{email: "test@example.com"}, %Member{email: "other@example.com"}]
|
||||
"""
|
||||
@spec filter_by_email_match([t()], String.t()) :: [t()]
|
||||
def filter_by_email_match(members, user_email)
|
||||
when is_list(members) and is_binary(user_email) do
|
||||
email_match = Enum.find(members, &(&1.email == user_email))
|
||||
|
||||
if email_match do
|
||||
# Email match found - return only this member (highest priority)
|
||||
[email_match]
|
||||
else
|
||||
# No email match - return all members unchanged
|
||||
members
|
||||
end
|
||||
end
|
||||
|
||||
@spec filter_by_email_match(any(), any()) :: any()
|
||||
def filter_by_email_match(members, _user_email), do: members
|
||||
|
||||
validations do
|
||||
# Required fields are covered by allow_nil? false
|
||||
|
||||
|
|
@ -100,6 +246,10 @@ defmodule Mv.Membership.Member do
|
|||
validate present(:last_name)
|
||||
validate present(:email)
|
||||
|
||||
# Email uniqueness check for all actions that change the email attribute
|
||||
# Validates that member email is not already used by another (unlinked) user
|
||||
validate Mv.Membership.Member.Validations.EmailNotUsedByOtherUser
|
||||
|
||||
# Prevent linking to a user that already has a member
|
||||
# This validation prevents "stealing" users from other members by checking
|
||||
# if the target user is already linked to a different member
|
||||
|
|
@ -134,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)],
|
||||
|
|
@ -189,15 +334,18 @@ defmodule Mv.Membership.Member do
|
|||
constraints min_length: 1
|
||||
end
|
||||
|
||||
# IMPORTANT: Email Synchronization
|
||||
# When member and user are linked, emails are automatically synced bidirectionally.
|
||||
# User.email is the source of truth - when a link is established, member.email
|
||||
# is overridden to match user.email. Subsequent changes to either email will
|
||||
# sync to the other resource.
|
||||
# See: Mv.EmailSync.Changes.SyncUserEmailToMember
|
||||
# Mv.EmailSync.Changes.SyncMemberEmailToUser
|
||||
attribute :email, :string do
|
||||
allow_nil? false
|
||||
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
|
||||
|
|
@ -241,7 +389,7 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
|
||||
relationships do
|
||||
has_many :properties, Mv.Membership.Property
|
||||
has_many :custom_field_values, Mv.Membership.CustomFieldValue
|
||||
# 1:1 relationship - Member can optionally have one User
|
||||
# This references the User's member_id attribute
|
||||
# The relationship is optional (allow_nil? true by default)
|
||||
|
|
@ -252,4 +400,102 @@ defmodule Mv.Membership.Member do
|
|||
identities do
|
||||
identity :unique_email, [:email]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Performs fuzzy search on members using PostgreSQL trigram similarity.
|
||||
|
||||
Wraps the `:search` action with convenient opts-based argument passing.
|
||||
Searches across first_name, last_name, email, and other text fields using
|
||||
full-text search combined with trigram similarity.
|
||||
|
||||
## Parameters
|
||||
- `query` - Ash.Query.t() to apply search to
|
||||
- `opts` - Keyword list or map with search options:
|
||||
- `:query` or `"query"` - Search string
|
||||
- `:fields` or `"fields"` - Optional field restrictions
|
||||
|
||||
## Returns
|
||||
- Modified Ash.Query.t() with search filters applied
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Member |> fuzzy_search(%{query: "Greta"}) |> Ash.read!()
|
||||
[%Member{first_name: "Greta", ...}]
|
||||
|
||||
iex> Member |> fuzzy_search(%{query: "gre"}) |> Ash.read!() # typo-tolerant
|
||||
[%Member{first_name: "Greta", ...}]
|
||||
|
||||
"""
|
||||
@spec fuzzy_search(Ash.Query.t(), keyword() | map()) :: Ash.Query.t()
|
||||
def fuzzy_search(query, opts) do
|
||||
q = (opts[:query] || opts["query"] || "") |> to_string()
|
||||
|
||||
if String.trim(q) == "" do
|
||||
query
|
||||
else
|
||||
args =
|
||||
case opts[:fields] || opts["fields"] do
|
||||
nil -> %{query: q}
|
||||
fields -> %{query: q, fields: fields}
|
||||
end
|
||||
|
||||
Ash.Query.for_read(query, :search, args)
|
||||
end
|
||||
end
|
||||
|
||||
# Private helper to apply filters for :available_for_linking action
|
||||
# user_email: may be nil/empty when creating new user, or populated when editing
|
||||
# search_query: optional search term for fuzzy matching
|
||||
#
|
||||
# Logic: (email == user_email) OR (fuzzy_search on search_query)
|
||||
# - Empty user_email ("") → email == "" is always false → only fuzzy search matches
|
||||
# - This allows a single filter expression instead of duplicating fuzzy search logic
|
||||
#
|
||||
# Cyclomatic complexity is unavoidable here: PostgreSQL fuzzy search requires
|
||||
# multiple OR conditions for good search quality (FTS + trigram similarity + substring)
|
||||
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
|
||||
defp apply_linking_filters(query, user_email, search_query) do
|
||||
has_search = search_query && String.trim(search_query) != ""
|
||||
# Use empty string instead of nil to simplify filter logic
|
||||
trimmed_email = if user_email, do: String.trim(user_email), else: ""
|
||||
|
||||
if has_search do
|
||||
# Search query provided: return email-match OR fuzzy-search candidates
|
||||
trimmed_search = String.trim(search_query)
|
||||
|
||||
query
|
||||
|> Ash.Query.filter(
|
||||
expr(
|
||||
# Email match candidate (for filter_by_email_match priority)
|
||||
# If email is "", this is always false and fuzzy search takes over
|
||||
# Fuzzy search candidates
|
||||
email == ^trimmed_email or
|
||||
fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^trimmed_search) or
|
||||
fragment("search_vector @@ plainto_tsquery('simple', ?)", ^trimmed_search) or
|
||||
fragment("? % first_name", ^trimmed_search) or
|
||||
fragment("? % last_name", ^trimmed_search) or
|
||||
fragment("word_similarity(?, first_name) > 0.2", ^trimmed_search) or
|
||||
fragment(
|
||||
"word_similarity(?, last_name) > ?",
|
||||
^trimmed_search,
|
||||
^@default_similarity_threshold
|
||||
) or
|
||||
fragment(
|
||||
"similarity(first_name, ?) > ?",
|
||||
^trimmed_search,
|
||||
^@default_similarity_threshold
|
||||
) or
|
||||
fragment(
|
||||
"similarity(last_name, ?) > ?",
|
||||
^trimmed_search,
|
||||
^@default_similarity_threshold
|
||||
) or
|
||||
contains(email, ^trimmed_search)
|
||||
)
|
||||
)
|
||||
else
|
||||
# No search query: return all unlinked (filter_by_email_match will prioritize email if provided)
|
||||
query
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,23 @@
|
|||
defmodule Mv.Membership do
|
||||
@moduledoc """
|
||||
Ash Domain for membership management.
|
||||
|
||||
## Resources
|
||||
- `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.
|
||||
"""
|
||||
use Ash.Domain,
|
||||
extensions: [AshAdmin.Domain, AshPhoenix]
|
||||
|
||||
|
|
@ -14,18 +33,128 @@ defmodule Mv.Membership do
|
|||
define :destroy_member, action: :destroy
|
||||
end
|
||||
|
||||
resource Mv.Membership.Property do
|
||||
define :create_property, action: :create
|
||||
define :list_property, action: :read
|
||||
define :update_property, action: :update
|
||||
define :destroy_property, action: :destroy
|
||||
resource Mv.Membership.CustomFieldValue do
|
||||
define :create_custom_field_value, action: :create
|
||||
define :list_custom_field_values, action: :read
|
||||
define :update_custom_field_value, action: :update
|
||||
define :destroy_custom_field_value, action: :destroy
|
||||
end
|
||||
|
||||
resource Mv.Membership.PropertyType do
|
||||
define :create_property_type, action: :create
|
||||
define :list_property_types, action: :read
|
||||
define :update_property_type, action: :update
|
||||
define :destroy_property_type, action: :destroy
|
||||
resource Mv.Membership.CustomField do
|
||||
define :create_custom_field, action: :create
|
||||
define :list_custom_fields, action: :read
|
||||
define :update_custom_field, action: :update
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
defmodule Mv.Membership.Property do
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "properties"
|
||||
repo Mv.Repo
|
||||
|
||||
references do
|
||||
reference :member, on_delete: :delete
|
||||
end
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
default_accept [:value, :member_id, :property_type_id]
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
attribute :value, :union,
|
||||
constraints: [
|
||||
storage: :type_and_value,
|
||||
types: [
|
||||
boolean: [type: :boolean],
|
||||
date: [type: :date],
|
||||
integer: [type: :integer],
|
||||
string: [type: :string],
|
||||
email: [type: Mv.Membership.Email]
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :member, Mv.Membership.Member
|
||||
|
||||
belongs_to :property_type, Mv.Membership.PropertyType
|
||||
end
|
||||
|
||||
calculations do
|
||||
calculate :value_to_string, :string, expr(value[:value] <> "")
|
||||
end
|
||||
|
||||
# Ensure a member can only have one property per property type
|
||||
# For example: A member can have only one "email" property, one "phone" property, etc.
|
||||
identities do
|
||||
identity :unique_property_per_member, [:member_id, :property_type_id]
|
||||
end
|
||||
end
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
defmodule Mv.Membership.PropertyType do
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "property_types"
|
||||
repo Mv.Repo
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
default_accept [:name, :value_type, :description, :immutable, :required]
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
attribute :name, :string, allow_nil?: false, public?: true
|
||||
|
||||
attribute :value_type, :atom,
|
||||
constraints: [one_of: [:string, :integer, :boolean, :date, :email]],
|
||||
allow_nil?: false,
|
||||
description: "Defines the datatype `Property.value` is interpreted as"
|
||||
|
||||
attribute :description, :string, allow_nil?: true, public?: true
|
||||
|
||||
attribute :immutable, :boolean,
|
||||
default: false,
|
||||
allow_nil?: false
|
||||
|
||||
attribute :required, :boolean,
|
||||
default: false,
|
||||
allow_nil?: false
|
||||
end
|
||||
|
||||
relationships do
|
||||
has_many :properties, Mv.Membership.Property
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_name, [:name]
|
||||
end
|
||||
end
|
||||
138
lib/membership/setting.ex
Normal file
138
lib/membership/setting.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -10,6 +10,20 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
|||
|
||||
alias Mv.Mailer
|
||||
|
||||
@doc """
|
||||
Sends a confirmation email to a new user.
|
||||
|
||||
This function is called automatically by AshAuthentication when a new
|
||||
user registers and needs to confirm their email address.
|
||||
|
||||
## Parameters
|
||||
- `user` - The user record who needs to confirm their email
|
||||
- `token` - The confirmation token to include in the email link
|
||||
- `_opts` - Additional options (unused)
|
||||
|
||||
## Returns
|
||||
The Swoosh.Email delivery result from `Mailer.deliver!/1`.
|
||||
"""
|
||||
@impl true
|
||||
def send(user, token, _) do
|
||||
new()
|
||||
|
|
|
|||
|
|
@ -10,6 +10,20 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
|
|||
|
||||
alias Mv.Mailer
|
||||
|
||||
@doc """
|
||||
Sends a password reset email to a user.
|
||||
|
||||
This function is called automatically by AshAuthentication when a user
|
||||
requests a password reset.
|
||||
|
||||
## Parameters
|
||||
- `user` - The user record requesting the password reset
|
||||
- `token` - The password reset token to include in the email link
|
||||
- `_opts` - Additional options (unused)
|
||||
|
||||
## Returns
|
||||
The Swoosh.Email delivery result from `Mailer.deliver!/1`.
|
||||
"""
|
||||
@impl true
|
||||
def send(user, token, _) do
|
||||
new()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
|
||||
@moduledoc """
|
||||
Validates that the user's email is not already used by another member.
|
||||
Only validates when:
|
||||
- User is already linked to a member (member_id != nil) AND email is changing
|
||||
- User is being linked to a member (member relationship is changing)
|
||||
|
||||
This allows creating users with the same email as unlinked members.
|
||||
"""
|
||||
use Ash.Resource.Validation
|
||||
|
||||
@doc """
|
||||
Validates email uniqueness across linked User-Member pairs.
|
||||
|
||||
This validation ensures that when a user is linked to a member, their email
|
||||
does not conflict with another member's email. It only runs when necessary
|
||||
to avoid blocking valid operations (see `@moduledoc` for trigger conditions).
|
||||
|
||||
## Parameters
|
||||
- `changeset` - The Ash changeset being validated
|
||||
- `_opts` - Options passed to the validation (unused)
|
||||
- `_context` - Ash context map (unused)
|
||||
|
||||
## Returns
|
||||
- `:ok` if validation passes or should be skipped
|
||||
- `{:error, field: :email, message: ..., value: ...}` if validation fails
|
||||
"""
|
||||
@impl true
|
||||
def validate(changeset, _opts, _context) do
|
||||
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
|
||||
member_changing? = Ash.Changeset.changing_relationship?(changeset, :member)
|
||||
|
||||
member_id = Ash.Changeset.get_attribute(changeset, :member_id)
|
||||
is_linked? = not is_nil(member_id)
|
||||
|
||||
# Only validate if:
|
||||
# 1. User is linked AND email is changing
|
||||
# 2. User is being linked/unlinked (member relationship changing)
|
||||
should_validate? = (is_linked? and email_changing?) or member_changing?
|
||||
|
||||
if should_validate? do
|
||||
case Ash.Changeset.fetch_change(changeset, :email) do
|
||||
{:ok, new_email} ->
|
||||
# Extract member_id from relationship changes for new links
|
||||
member_id_to_exclude = get_member_id_from_changeset(changeset)
|
||||
check_email_uniqueness(new_email, member_id_to_exclude)
|
||||
|
||||
:error ->
|
||||
# No email change, get current email
|
||||
current_email = Ash.Changeset.get_attribute(changeset, :email)
|
||||
# Extract member_id from relationship changes for new links
|
||||
member_id_to_exclude = get_member_id_from_changeset(changeset)
|
||||
check_email_uniqueness(current_email, member_id_to_exclude)
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
# Extract member_id from changeset, checking relationship changes first
|
||||
# This is crucial for new links where member_id is in manage_relationship changes
|
||||
defp get_member_id_from_changeset(changeset) do
|
||||
# Try to get from relationships (for new links via manage_relationship)
|
||||
case Map.get(changeset.relationships, :member) do
|
||||
[{[%{id: id}], _opts}] when not is_nil(id) ->
|
||||
# Found in relationships - this is a new link
|
||||
id
|
||||
|
||||
_ ->
|
||||
# Fall back to attribute (for existing links)
|
||||
Ash.Changeset.get_attribute(changeset, :member_id)
|
||||
end
|
||||
end
|
||||
|
||||
defp check_email_uniqueness(email, exclude_member_id) do
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.filter(email == ^to_string(email))
|
||||
|> maybe_exclude_id(exclude_member_id)
|
||||
|
||||
case Ash.read(query) do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, _} ->
|
||||
{:error, field: :email, message: "is already used by another member", value: email}
|
||||
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_exclude_id(query, nil), do: query
|
||||
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
||||
end
|
||||
22
lib/mv/constants.ex
Normal file
22
lib/mv/constants.ex
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
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
|
||||
]
|
||||
|
||||
def member_fields, do: @member_fields
|
||||
end
|
||||
52
lib/mv/email_sync/changes/sync_member_email_to_user.ex
Normal file
52
lib/mv/email_sync/changes/sync_member_email_to_user.ex
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
defmodule Mv.EmailSync.Changes.SyncMemberEmailToUser do
|
||||
@moduledoc """
|
||||
Synchronizes Member.email → User.email
|
||||
|
||||
Trigger conditions are configured in resources via `where` clauses:
|
||||
- Member resource: Use `where: [changing(:email)]`
|
||||
|
||||
Used by Member resource for bidirectional email sync.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
alias Mv.EmailSync.{Helpers, Loader}
|
||||
|
||||
@doc """
|
||||
Implements the email synchronization from Member to User.
|
||||
|
||||
This function is called automatically by Ash when the configured trigger
|
||||
conditions are met (see `@moduledoc` for trigger details).
|
||||
|
||||
## Parameters
|
||||
- `changeset` - The Ash changeset being processed
|
||||
- `_opts` - Options passed to the change (unused)
|
||||
- `context` - Ash context map containing metadata (e.g., `:syncing_email` flag)
|
||||
|
||||
## Returns
|
||||
Modified changeset with email synchronization applied, or original changeset
|
||||
if recursion detected.
|
||||
"""
|
||||
@impl true
|
||||
def change(changeset, _opts, context) do
|
||||
# Only recursion protection needed - trigger logic is in `where` clauses
|
||||
if Map.get(context, :syncing_email, false) do
|
||||
changeset
|
||||
else
|
||||
sync_email(changeset)
|
||||
end
|
||||
end
|
||||
|
||||
defp sync_email(changeset) do
|
||||
new_email = Ash.Changeset.get_attribute(changeset, :email)
|
||||
|
||||
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
|
||||
result = callback.(cs)
|
||||
|
||||
with {:ok, member} <- Helpers.extract_record(result),
|
||||
linked_user <- Loader.get_linked_user(member) do
|
||||
Helpers.sync_email_to_linked_record(result, linked_user, new_email)
|
||||
else
|
||||
_ -> result
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
77
lib/mv/email_sync/changes/sync_user_email_to_member.ex
Normal file
77
lib/mv/email_sync/changes/sync_user_email_to_member.ex
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
@moduledoc """
|
||||
Synchronizes User.email → Member.email
|
||||
User.email is always the source of truth.
|
||||
|
||||
Trigger conditions are configured in resources via `where` clauses:
|
||||
- User resource: Use `where: [changing(:email)]` or `where: any([changing(:email), changing(:member)])`
|
||||
- Member resource: Use `where: [changing(:user)]`
|
||||
|
||||
Can be used by both User and Member resources.
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
alias Mv.EmailSync.{Helpers, Loader}
|
||||
|
||||
@doc """
|
||||
Implements the email synchronization from User to Member.
|
||||
|
||||
This function is called automatically by Ash when the configured trigger
|
||||
conditions are met (see `@moduledoc` for trigger details).
|
||||
|
||||
## Parameters
|
||||
- `changeset` - The Ash changeset being processed
|
||||
- `_opts` - Options passed to the change (unused)
|
||||
- `context` - Ash context map containing metadata (e.g., `:syncing_email` flag)
|
||||
|
||||
## Returns
|
||||
Modified changeset with email synchronization applied, or original changeset
|
||||
if recursion detected.
|
||||
"""
|
||||
@impl true
|
||||
def change(changeset, _opts, context) do
|
||||
# Only recursion protection needed - trigger logic is in `where` clauses
|
||||
if Map.get(context, :syncing_email, false) do
|
||||
changeset
|
||||
else
|
||||
sync_email(changeset)
|
||||
end
|
||||
end
|
||||
|
||||
defp sync_email(changeset) do
|
||||
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
|
||||
result = callback.(cs)
|
||||
|
||||
with {:ok, record} <- Helpers.extract_record(result),
|
||||
{:ok, user, member} <- get_user_and_member(record) do
|
||||
# When called from Member-side, we need to update the member in the result
|
||||
# When called from User-side, we update the linked member in DB only
|
||||
case record do
|
||||
%Mv.Membership.Member{} ->
|
||||
# Member-side: Override member email in result with user email
|
||||
Helpers.override_with_linked_email(result, user.email)
|
||||
|
||||
%Mv.Accounts.User{} ->
|
||||
# User-side: Sync user email to linked member in DB
|
||||
Helpers.sync_email_to_linked_record(result, member, user.email)
|
||||
end
|
||||
else
|
||||
_ -> result
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# Retrieves user and member - works for both resource types
|
||||
defp get_user_and_member(%Mv.Accounts.User{} = user) do
|
||||
case Loader.get_linked_member(user) do
|
||||
nil -> {:error, :no_member}
|
||||
member -> {:ok, user, member}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_user_and_member(%Mv.Membership.Member{} = member) do
|
||||
case Loader.load_linked_user!(member) do
|
||||
{:ok, user} -> {:ok, user, member}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
93
lib/mv/email_sync/helpers.ex
Normal file
93
lib/mv/email_sync/helpers.ex
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
defmodule Mv.EmailSync.Helpers do
|
||||
@moduledoc """
|
||||
Shared helper functions for email synchronization between User and Member.
|
||||
|
||||
Handles the complexity of `around_transaction` callback results and
|
||||
provides clean abstractions for email updates within transactions.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
import Ecto.Changeset
|
||||
|
||||
@doc """
|
||||
Extracts the record from an Ash action result.
|
||||
|
||||
Handles both 2-tuple `{:ok, record}` and 4-tuple
|
||||
`{:ok, record, changeset, notifications}` patterns.
|
||||
"""
|
||||
def extract_record({:ok, record, _changeset, _notifications}), do: {:ok, record}
|
||||
def extract_record({:ok, record}), do: {:ok, record}
|
||||
def extract_record({:error, _} = error), do: error
|
||||
|
||||
@doc """
|
||||
Updates the result with a new record while preserving the original structure.
|
||||
|
||||
If the original result was a 4-tuple, returns a 4-tuple with the updated record.
|
||||
If it was a 2-tuple, returns a 2-tuple with the updated record.
|
||||
"""
|
||||
def update_result_record({:ok, _old_record, changeset, notifications}, new_record) do
|
||||
{:ok, new_record, changeset, notifications}
|
||||
end
|
||||
|
||||
def update_result_record({:ok, _old_record}, new_record) do
|
||||
{:ok, new_record}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates an email field directly via Ecto within the current transaction.
|
||||
|
||||
This bypasses Ash's action system to ensure the update happens in the
|
||||
same database transaction as the parent action.
|
||||
"""
|
||||
def update_email_via_ecto(record, new_email) do
|
||||
record
|
||||
|> cast(%{email: to_string(new_email)}, [:email])
|
||||
|> Mv.Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Synchronizes email to a linked record if it exists.
|
||||
|
||||
Returns the original result unchanged, or an error if sync fails.
|
||||
"""
|
||||
def sync_email_to_linked_record(result, linked_record, new_email) do
|
||||
with {:ok, _source} <- extract_record(result),
|
||||
record when not is_nil(record) <- linked_record,
|
||||
{:ok, _updated} <- update_email_via_ecto(record, new_email) do
|
||||
# Successfully synced - return original result unchanged
|
||||
result
|
||||
else
|
||||
nil ->
|
||||
# No linked record - return original result
|
||||
result
|
||||
|
||||
{:error, error} ->
|
||||
# Sync failed - log and propagate error to rollback transaction
|
||||
Logger.error("Email sync failed: #{inspect(error)}")
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Overrides the record's email with the linked email if emails differ.
|
||||
|
||||
Returns updated result with new record, or original result if no update needed.
|
||||
"""
|
||||
def override_with_linked_email(result, linked_email) do
|
||||
with {:ok, record} <- extract_record(result),
|
||||
true <- record.email != to_string(linked_email),
|
||||
{:ok, updated_record} <- update_email_via_ecto(record, linked_email) do
|
||||
# Email was different - return result with updated record
|
||||
update_result_record(result, updated_record)
|
||||
else
|
||||
false ->
|
||||
# Emails already match - no update needed
|
||||
result
|
||||
|
||||
{:error, error} ->
|
||||
# Override failed - log and propagate error
|
||||
Logger.error("Email override failed: #{inspect(error)}")
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
end
|
||||
40
lib/mv/email_sync/loader.ex
Normal file
40
lib/mv/email_sync/loader.ex
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
defmodule Mv.EmailSync.Loader do
|
||||
@moduledoc """
|
||||
Helper functions for loading linked records in email synchronization.
|
||||
Centralizes the logic for retrieving related User/Member entities.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Loads the member linked to a user, returns nil if not linked or on error.
|
||||
"""
|
||||
def get_linked_member(%{member_id: nil}), do: nil
|
||||
|
||||
def get_linked_member(%{member_id: id}) do
|
||||
case Ash.get(Mv.Membership.Member, id) do
|
||||
{:ok, member} -> member
|
||||
{:error, _} -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads the user linked to a member, returns nil if not linked or on error.
|
||||
"""
|
||||
def get_linked_user(member) do
|
||||
case Ash.load(member, :user) do
|
||||
{:ok, %{user: user}} -> user
|
||||
{:error, _} -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads the user linked to a member, returning an error tuple if not linked.
|
||||
Useful when a link is required for the operation.
|
||||
"""
|
||||
def load_linked_user!(member) do
|
||||
case Ash.load(member, :user) do
|
||||
{:ok, %{user: user}} when not is_nil(user) -> {:ok, user}
|
||||
{:ok, _} -> {:error, :no_linked_user}
|
||||
{:error, _} = error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
||||
@moduledoc """
|
||||
Validates that the member's email is not already used by another user.
|
||||
Only validates when:
|
||||
- Member is already linked to a user (user != nil) AND email is changing
|
||||
- Member is being linked to a user (user relationship is changing)
|
||||
|
||||
This allows creating members with the same email as unlinked users.
|
||||
"""
|
||||
use Ash.Resource.Validation
|
||||
|
||||
@doc """
|
||||
Validates email uniqueness across linked Member-User pairs.
|
||||
|
||||
This validation ensures that when a member is linked to a user, their email
|
||||
does not conflict with another user's email. It only runs when necessary
|
||||
to avoid blocking valid operations (see `@moduledoc` for trigger conditions).
|
||||
|
||||
## Parameters
|
||||
- `changeset` - The Ash changeset being validated
|
||||
- `_opts` - Options passed to the validation (unused)
|
||||
- `_context` - Ash context map (unused)
|
||||
|
||||
## Returns
|
||||
- `:ok` if validation passes or should be skipped
|
||||
- `{:error, field: :email, message: ..., value: ...}` if validation fails
|
||||
"""
|
||||
@impl true
|
||||
def validate(changeset, _opts, _context) do
|
||||
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
|
||||
|
||||
linked_user_id = get_linked_user_id(changeset.data)
|
||||
is_linked? = not is_nil(linked_user_id)
|
||||
|
||||
# Only validate if member is already linked AND email is changing
|
||||
# Do NOT validate when member is being linked (email will be overridden from user)
|
||||
should_validate? = is_linked? and email_changing?
|
||||
|
||||
if should_validate? do
|
||||
new_email = Ash.Changeset.get_attribute(changeset, :email)
|
||||
check_email_uniqueness(new_email, linked_user_id)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp check_email_uniqueness(email, exclude_user_id) do
|
||||
query =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(email == ^email)
|
||||
|> maybe_exclude_id(exclude_user_id)
|
||||
|
||||
case Ash.read(query) do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, _} ->
|
||||
{:error, field: :email, message: "is already used by another user", value: email}
|
||||
|
||||
{:error, _} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_exclude_id(query, nil), do: query
|
||||
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
||||
|
||||
defp get_linked_user_id(member_data) do
|
||||
case Ash.load(member_data, :user) do
|
||||
{:ok, %{user: %{id: id}}} -> id
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -5,7 +5,7 @@ defmodule Mv.Repo do
|
|||
@impl true
|
||||
def installed_extensions do
|
||||
# Add extensions here, and the migration generator will install them.
|
||||
["ash-functions", "citext"]
|
||||
["ash-functions", "citext", "pg_trgm"]
|
||||
end
|
||||
|
||||
# Don't open unnecessary transactions
|
||||
|
|
|
|||
|
|
@ -1,4 +1,23 @@
|
|||
defmodule Mv.Secrets do
|
||||
@moduledoc """
|
||||
Secret provider for AshAuthentication.
|
||||
|
||||
## Purpose
|
||||
Provides runtime configuration secrets for Ash Authentication strategies,
|
||||
particularly for OIDC (Rauthy) authentication.
|
||||
|
||||
## Configuration Source
|
||||
Secrets are read from the `:rauthy` key in the application configuration,
|
||||
which is typically set in `config/runtime.exs` from environment variables:
|
||||
- `OIDC_CLIENT_ID`
|
||||
- `OIDC_CLIENT_SECRET`
|
||||
- `OIDC_BASE_URL`
|
||||
- `OIDC_REDIRECT_URI`
|
||||
|
||||
## Usage
|
||||
This module is automatically called by AshAuthentication when resolving
|
||||
secrets for the User resource's OIDC strategy.
|
||||
"""
|
||||
use AshAuthentication.Secret
|
||||
|
||||
def secret_for(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,16 @@
|
|||
defmodule MvWeb.AuthOverrides do
|
||||
@moduledoc """
|
||||
UI customizations for AshAuthentication Phoenix components.
|
||||
|
||||
## Overrides
|
||||
- `SignIn` - Restricts form width to prevent full-width display
|
||||
- `Banner` - Replaces default logo with "Mitgliederverwaltung" text
|
||||
- `HorizontalRule` - Translates "or" text to German
|
||||
|
||||
## Documentation
|
||||
For complete reference on available overrides, see:
|
||||
https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
|
||||
"""
|
||||
use AshAuthentication.Phoenix.Overrides
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
|
|
|
|||
|
|
@ -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,16 +60,20 @@ 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}
|
||||
>
|
||||
<div class={[
|
||||
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
|
||||
@kind == :info && "alert-info",
|
||||
@kind == :error && "alert-error"
|
||||
@kind == :error && "alert-error",
|
||||
@kind == :success && "bg-green-500 text-white",
|
||||
@kind == :warning && "bg-blue-100 text-blue-800 border border-blue-300"
|
||||
]}>
|
||||
<.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" />
|
||||
<div>
|
||||
<p :if={@title} class="font-semibold">{@title}</p>
|
||||
<p>{msg}</p>
|
||||
|
|
@ -180,7 +188,7 @@ defmodule MvWeb.CoreComponents do
|
|||
end)
|
||||
|
||||
~H"""
|
||||
<fieldset class="fieldset mb-2">
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
|
||||
<span class="label">
|
||||
|
|
@ -192,7 +200,11 @@ defmodule MvWeb.CoreComponents do
|
|||
checked={@checked}
|
||||
class={@class || "checkbox checkbox-sm"}
|
||||
{@rest}
|
||||
/>{@label}
|
||||
/>{@label}<span
|
||||
:if={@rest[:required]}
|
||||
class="text-red-700 tooltip tooltip-right"
|
||||
data-tip={gettext("This field cannot be empty")}
|
||||
>*</span>
|
||||
</span>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
|
|
@ -202,9 +214,15 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
def input(%{type: "select"} = assigns) do
|
||||
~H"""
|
||||
<fieldset class="fieldset mb-2">
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<span :if={@label} class="label mb-1">{@label}</span>
|
||||
<span :if={@label} class="mb-1 label">
|
||||
{@label}<span
|
||||
:if={@rest[:required]}
|
||||
class="text-red-700 tooltip tooltip-right"
|
||||
data-tip={gettext("This field cannot be empty")}
|
||||
>*</span>
|
||||
</span>
|
||||
<select
|
||||
id={@id}
|
||||
name={@name}
|
||||
|
|
@ -223,9 +241,15 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
def input(%{type: "textarea"} = assigns) do
|
||||
~H"""
|
||||
<fieldset class="fieldset mb-2">
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<span :if={@label} class="label mb-1">{@label}</span>
|
||||
<span :if={@label} class="mb-1 label">
|
||||
{@label}<span
|
||||
:if={@rest[:required]}
|
||||
class="text-red-700 tooltip tooltip-right"
|
||||
data-tip={gettext("This field cannot be empty")}
|
||||
>*</span>
|
||||
</span>
|
||||
<textarea
|
||||
id={@id}
|
||||
name={@name}
|
||||
|
|
@ -244,9 +268,15 @@ defmodule MvWeb.CoreComponents do
|
|||
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||
def input(assigns) do
|
||||
~H"""
|
||||
<fieldset class="fieldset mb-2">
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<span :if={@label} class="label mb-1">{@label}</span>
|
||||
<span :if={@label} class="mb-1 label">
|
||||
{@label}<span
|
||||
:if={@rest[:required]}
|
||||
class="text-red-700 tooltip tooltip-right"
|
||||
data-tip={gettext("This field cannot be empty")}
|
||||
>*</span>
|
||||
</span>
|
||||
<input
|
||||
type={@type}
|
||||
name={@name}
|
||||
|
|
@ -318,6 +348,13 @@ defmodule MvWeb.CoreComponents do
|
|||
default: &Function.identity/1,
|
||||
doc: "the function for mapping each row before calling the :col and :action slots"
|
||||
|
||||
attr :dynamic_cols, :list,
|
||||
default: [],
|
||||
doc: "list of dynamic column definitions with :custom_field and :render functions"
|
||||
|
||||
attr :sort_field, :any, default: nil, doc: "current sort field"
|
||||
attr :sort_order, :atom, default: nil, doc: "current sort order"
|
||||
|
||||
slot :col, required: true do
|
||||
attr :label, :string
|
||||
end
|
||||
|
|
@ -335,6 +372,16 @@ defmodule MvWeb.CoreComponents do
|
|||
<thead>
|
||||
<tr>
|
||||
<th :for={col <- @col}>{col[:label]}</th>
|
||||
<th :for={dyn_col <- @dynamic_cols}>
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
|
||||
field={"custom_field_#{dyn_col[:custom_field].id}"}
|
||||
label={dyn_col[:custom_field].name}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
</th>
|
||||
<th :if={@action != []}>
|
||||
<span class="sr-only">{gettext("Actions")}</span>
|
||||
</th>
|
||||
|
|
@ -349,6 +396,23 @@ defmodule MvWeb.CoreComponents do
|
|||
>
|
||||
{render_slot(col, @row_item.(row))}
|
||||
</td>
|
||||
<td
|
||||
:for={dyn_col <- @dynamic_cols}
|
||||
phx-click={@row_click && @row_click.(row)}
|
||||
class={@row_click && "hover:cursor-pointer"}
|
||||
>
|
||||
{if dyn_col[:render] do
|
||||
rendered = dyn_col[:render].(@row_item.(row))
|
||||
|
||||
if rendered == "" do
|
||||
""
|
||||
else
|
||||
rendered
|
||||
end
|
||||
else
|
||||
""
|
||||
end}
|
||||
</td>
|
||||
<td :if={@action != []} class="w-0 font-semibold">
|
||||
<div class="flex gap-4">
|
||||
<%= for action <- @action do %>
|
||||
|
|
@ -483,7 +547,7 @@ defmodule MvWeb.CoreComponents do
|
|||
<div class="mt-14">
|
||||
<dl class="-my-4 divide-y divide-zinc-100">
|
||||
<div :for={{name, value} <- @items} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
|
||||
<dt class="w-1/4 flex-none text-zinc-500">{name}</dt>
|
||||
<dt class="flex-none w-1/4 text-zinc-500">{name}</dt>
|
||||
<dd class="text-zinc-700">{value}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
|
|
|||
|
|
@ -65,7 +65,9 @@ defmodule MvWeb.Layouts do
|
|||
|
||||
def flash_group(assigns) do
|
||||
~H"""
|
||||
<div id={@id} aria-live="polite">
|
||||
<div id={@id} aria-live="polite" class="toast toast-top toast-end z-50 flex flex-col gap-2">
|
||||
<.flash kind={:success} flash={@flash} />
|
||||
<.flash kind={:warning} flash={@flash} />
|
||||
<.flash kind={:info} flash={@flash} />
|
||||
<.flash kind={:error} flash={@flash} />
|
||||
|
||||
|
|
|
|||
|
|
@ -6,17 +6,24 @@ defmodule MvWeb.Layouts.Navbar do
|
|||
use Gettext, backend: MvWeb.Gettext
|
||||
use MvWeb, :verified_routes
|
||||
|
||||
alias Mv.Membership
|
||||
|
||||
attr :current_user, :map,
|
||||
required: true,
|
||||
doc: "The current user - navbar is only shown when user is present"
|
||||
|
||||
def navbar(assigns) do
|
||||
club_name = get_club_name()
|
||||
|
||||
assigns = assign(assigns, :club_name, club_name)
|
||||
|
||||
~H"""
|
||||
<header class="navbar bg-base-100 shadow-sm">
|
||||
<div class="flex-1">
|
||||
<a class="btn btn-ghost text-xl">Mitgliederverwaltung</a>
|
||||
<a class="btn btn-ghost text-xl">{@club_name}</a>
|
||||
<ul class="menu menu-horizontal bg-base-200">
|
||||
<li><.link navigate="/members">{gettext("Members")}</.link></li>
|
||||
<li><.link navigate="/custom_fields">{gettext("Custom Fields")}</.link></li>
|
||||
<li><.link navigate="/users">{gettext("Users")}</.link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -88,7 +95,9 @@ defmodule MvWeb.Layouts.Navbar do
|
|||
{gettext("Profil")}
|
||||
</.link>
|
||||
</li>
|
||||
<li><a>{gettext("Settings")}</a></li>
|
||||
<li>
|
||||
<.link navigate={~p"/settings"}>{gettext("Settings")}</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/sign-out"}>{gettext("Logout")}</.link>
|
||||
</li>
|
||||
|
|
@ -98,4 +107,13 @@ defmodule MvWeb.Layouts.Navbar do
|
|||
</header>
|
||||
"""
|
||||
end
|
||||
|
||||
# Helper function to get club name from settings
|
||||
# Falls back to "Mitgliederverwaltung" if settings can't be loaded
|
||||
defp get_club_name do
|
||||
case Membership.get_settings() do
|
||||
{:ok, settings} -> settings.club_name
|
||||
_ -> "Mitgliederverwaltung"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,9 +1,21 @@
|
|||
require Logger
|
||||
|
||||
defmodule MvWeb.AuthController do
|
||||
@moduledoc """
|
||||
Handles authentication callbacks for password and OIDC authentication.
|
||||
|
||||
This controller manages:
|
||||
- Successful authentication (password, OIDC, password reset, email confirmation)
|
||||
- Authentication failures with appropriate error handling
|
||||
- OIDC account linking flow when email collision occurs
|
||||
- Sign out functionality
|
||||
"""
|
||||
|
||||
use MvWeb, :controller
|
||||
use AshAuthentication.Phoenix.Controller
|
||||
|
||||
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||
|
||||
def success(conn, activity, user, _token) do
|
||||
return_to = get_session(conn, :return_to) || ~p"/"
|
||||
|
||||
|
|
@ -23,26 +35,144 @@ defmodule MvWeb.AuthController do
|
|||
|> redirect(to: return_to)
|
||||
end
|
||||
|
||||
def failure(conn, activity, reason) do
|
||||
Logger.error(%{conn: conn, reason: reason})
|
||||
@doc """
|
||||
Handles authentication failures and routes to appropriate error handling.
|
||||
|
||||
Manages:
|
||||
- OIDC email collisions (triggers password verification flow)
|
||||
- Generic OIDC authentication failures
|
||||
- Unconfirmed account errors
|
||||
- Generic authentication failures
|
||||
"""
|
||||
def failure(conn, activity, reason) do
|
||||
Logger.warning(
|
||||
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
message =
|
||||
case {activity, reason} do
|
||||
{_,
|
||||
%AshAuthentication.Errors.AuthenticationFailed{
|
||||
caused_by: %Ash.Error.Forbidden{
|
||||
errors: [%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}]
|
||||
}
|
||||
}} ->
|
||||
{{:rauthy, _action}, reason} ->
|
||||
handle_rauthy_failure(conn, reason)
|
||||
|
||||
{_, %AshAuthentication.Errors.AuthenticationFailed{caused_by: caused_by}} ->
|
||||
handle_authentication_failed(conn, caused_by)
|
||||
|
||||
_ ->
|
||||
redirect_with_error(conn, gettext("Incorrect email or password"))
|
||||
end
|
||||
end
|
||||
|
||||
# Handle all Rauthy (OIDC) authentication failures
|
||||
defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do
|
||||
handle_oidc_email_collision(conn, errors)
|
||||
end
|
||||
|
||||
defp handle_rauthy_failure(conn, %AshAuthentication.Errors.AuthenticationFailed{
|
||||
caused_by: caused_by
|
||||
}) do
|
||||
case caused_by do
|
||||
%Ash.Error.Invalid{errors: errors} ->
|
||||
handle_oidc_email_collision(conn, errors)
|
||||
|
||||
_ ->
|
||||
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
|
||||
end
|
||||
end
|
||||
|
||||
# Handle generic AuthenticationFailed errors
|
||||
defp handle_authentication_failed(conn, %Ash.Error.Forbidden{errors: errors}) do
|
||||
if Enum.any?(errors, &match?(%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}, &1)) do
|
||||
message =
|
||||
gettext("""
|
||||
You have already signed in another way, but have not confirmed your account.
|
||||
You can confirm your account using the link we sent to you, or by resetting your password.
|
||||
""")
|
||||
|
||||
_ ->
|
||||
gettext("Incorrect email or password")
|
||||
redirect_with_error(conn, message)
|
||||
else
|
||||
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_authentication_failed(conn, _other) do
|
||||
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
|
||||
end
|
||||
|
||||
# Handle OIDC email collision - user needs to verify password to link accounts
|
||||
defp handle_oidc_email_collision(conn, errors) do
|
||||
case find_password_verification_error(errors) do
|
||||
%PasswordVerificationRequired{user_id: user_id, oidc_user_info: oidc_user_info} ->
|
||||
redirect_to_account_linking(conn, user_id, oidc_user_info)
|
||||
|
||||
nil ->
|
||||
# Check if it's a "different OIDC account" error or email uniqueness error
|
||||
error_message = extract_meaningful_error_message(errors)
|
||||
redirect_with_error(conn, error_message)
|
||||
end
|
||||
end
|
||||
|
||||
# Extract meaningful error message from Ash errors
|
||||
defp extract_meaningful_error_message(errors) do
|
||||
# Look for specific error messages in InvalidAttribute errors
|
||||
meaningful_error =
|
||||
Enum.find_value(errors, fn
|
||||
%Ash.Error.Changes.InvalidAttribute{message: message, field: :email}
|
||||
when is_binary(message) ->
|
||||
cond do
|
||||
# Email update conflict during OIDC login
|
||||
String.contains?(message, "Cannot update email to") and
|
||||
String.contains?(message, "already registered to another account") ->
|
||||
gettext(
|
||||
"Cannot update email: This email is already registered to another account. Please change your email in the identity provider."
|
||||
)
|
||||
|
||||
# Different OIDC account error
|
||||
String.contains?(message, "already linked to a different OIDC account") ->
|
||||
gettext(
|
||||
"This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
|
||||
)
|
||||
|
||||
true ->
|
||||
nil
|
||||
end
|
||||
|
||||
%Ash.Error.Changes.InvalidAttribute{message: message}
|
||||
when is_binary(message) ->
|
||||
# Return any other meaningful message
|
||||
if String.length(message) > 20 and
|
||||
not String.contains?(message, "has already been taken") do
|
||||
message
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end)
|
||||
|
||||
meaningful_error || gettext("Unable to sign in. Please try again.")
|
||||
end
|
||||
|
||||
# Find PasswordVerificationRequired error in error list
|
||||
defp find_password_verification_error(errors) do
|
||||
Enum.find(errors, &match?(%PasswordVerificationRequired{}, &1))
|
||||
end
|
||||
|
||||
# Redirect to account linking page with OIDC info stored in session
|
||||
defp redirect_to_account_linking(conn, user_id, oidc_user_info) do
|
||||
conn
|
||||
|> put_session(:oidc_linking_user_id, user_id)
|
||||
|> put_session(:oidc_linking_user_info, oidc_user_info)
|
||||
|> put_flash(
|
||||
:info,
|
||||
gettext(
|
||||
"An account with this email already exists. Please verify your password to link your OIDC account."
|
||||
)
|
||||
)
|
||||
|> redirect(to: ~p"/auth/link-oidc-account")
|
||||
end
|
||||
|
||||
# Generic error redirect helper
|
||||
defp redirect_with_error(conn, message) do
|
||||
conn
|
||||
|> put_flash(:error, message)
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
|
|
|
|||
296
lib/mv_web/live/auth/link_oidc_account_live.ex
Normal file
296
lib/mv_web/live/auth/link_oidc_account_live.ex
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
defmodule MvWeb.LinkOidcAccountLive do
|
||||
@moduledoc """
|
||||
LiveView for linking an OIDC account to an existing password account.
|
||||
|
||||
This page is shown when a user tries to log in via OIDC using an email
|
||||
that already exists with a password-only account. The user must verify
|
||||
their password before the OIDC account can be linked.
|
||||
|
||||
## Flow
|
||||
1. User attempts OIDC login with email that has existing password account
|
||||
2. System raises `PasswordVerificationRequired` error
|
||||
3. AuthController redirects here with user_id and oidc_user_info in session
|
||||
4. User enters password to verify identity
|
||||
5. On success, oidc_id is linked to user account
|
||||
6. User is redirected to complete OIDC login
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
with user_id when not is_nil(user_id) <- Map.get(session, "oidc_linking_user_id"),
|
||||
oidc_user_info when not is_nil(oidc_user_info) <-
|
||||
Map.get(session, "oidc_linking_user_info"),
|
||||
{:ok, user} <- Ash.get(Mv.Accounts.User, user_id) do
|
||||
# Check if user is passwordless
|
||||
if passwordless?(user) do
|
||||
# Auto-link passwordless user immediately
|
||||
{:ok, auto_link_passwordless_user(socket, user, oidc_user_info)}
|
||||
else
|
||||
# Show password form for password-protected user
|
||||
{:ok, initialize_socket(socket, user, oidc_user_info)}
|
||||
end
|
||||
else
|
||||
nil ->
|
||||
{:ok, redirect_with_error(socket, dgettext("auth", "Invalid session. Please try again."))}
|
||||
|
||||
{:error, _} ->
|
||||
{:ok, redirect_with_error(socket, dgettext("auth", "Session expired. Please try again."))}
|
||||
end
|
||||
end
|
||||
|
||||
defp passwordless?(user) do
|
||||
is_nil(user.hashed_password)
|
||||
end
|
||||
|
||||
defp reload_user!(user_id) do
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(id == ^user_id)
|
||||
|> Ash.read_one!()
|
||||
end
|
||||
|
||||
defp reset_password_form(socket) do
|
||||
assign(socket, :form, to_form(%{"password" => ""}))
|
||||
end
|
||||
|
||||
defp auto_link_passwordless_user(socket, user, oidc_user_info) do
|
||||
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
|
||||
|
||||
case user.id
|
||||
|> reload_user!()
|
||||
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
||||
oidc_id: oidc_id,
|
||||
oidc_user_info: oidc_user_info
|
||||
})
|
||||
|> Ash.update() do
|
||||
{:ok, updated_user} ->
|
||||
Logger.info(
|
||||
"Passwordless account auto-linked to OIDC: user_id=#{updated_user.id}, oidc_id=#{oidc_id}"
|
||||
)
|
||||
|
||||
socket
|
||||
|> put_flash(
|
||||
:info,
|
||||
dgettext("auth", "Account activated! Redirecting to complete sign-in...")
|
||||
)
|
||||
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/rauthy")
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning(
|
||||
"Failed to auto-link passwordless account: user_id=#{user.id}, error=#{inspect(error)}"
|
||||
)
|
||||
|
||||
error_message = extract_user_friendly_error(error)
|
||||
|
||||
socket
|
||||
|> put_flash(:error, error_message)
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_user_friendly_error(%Ash.Error.Invalid{errors: errors}) do
|
||||
# Check for specific error types
|
||||
Enum.find_value(errors, fn
|
||||
%Ash.Error.Changes.InvalidAttribute{field: :oidc_id, message: message} ->
|
||||
if String.contains?(message, "already been taken") do
|
||||
dgettext(
|
||||
"auth",
|
||||
"This OIDC account is already linked to another user. Please contact support."
|
||||
)
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
%Ash.Error.Changes.InvalidAttribute{field: :email, message: message} ->
|
||||
if String.contains?(message, "already been taken") do
|
||||
dgettext(
|
||||
"auth",
|
||||
"The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
|
||||
)
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end) ||
|
||||
dgettext("auth", "Failed to link account. Please try again or contact support.")
|
||||
end
|
||||
|
||||
defp extract_user_friendly_error(_error) do
|
||||
dgettext("auth", "Failed to link account. Please try again or contact support.")
|
||||
end
|
||||
|
||||
defp initialize_socket(socket, user, oidc_user_info) do
|
||||
socket
|
||||
|> assign(:user, user)
|
||||
|> assign(:oidc_user_info, oidc_user_info)
|
||||
|> assign(:password, "")
|
||||
|> assign(:error, nil)
|
||||
|> reset_password_form()
|
||||
end
|
||||
|
||||
defp redirect_with_error(socket, message) do
|
||||
socket
|
||||
|> put_flash(:error, message)
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"password" => password}, socket) do
|
||||
{:noreply, assign(socket, :password, password)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("submit", %{"password" => password}, socket) do
|
||||
user = socket.assigns.user
|
||||
oidc_user_info = socket.assigns.oidc_user_info
|
||||
|
||||
# Verify the password using AshAuthentication
|
||||
case verify_password(user.email, password) do
|
||||
{:ok, verified_user} ->
|
||||
# Password correct - link the OIDC account
|
||||
link_oidc_account(socket, verified_user, oidc_user_info)
|
||||
|
||||
{:error, _reason} ->
|
||||
# Password incorrect - log security event
|
||||
Logger.warning("Failed password verification for OIDC linking: user_email=#{user.email}")
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:error, dgettext("auth", "Incorrect password. Please try again."))
|
||||
|> reset_password_form()}
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_password(email, password) do
|
||||
# Use AshAuthentication password strategy to verify
|
||||
strategies = AshAuthentication.Info.authentication_strategies(Mv.Accounts.User)
|
||||
password_strategy = Enum.find(strategies, fn s -> s.name == :password end)
|
||||
|
||||
if password_strategy do
|
||||
AshAuthentication.Strategy.Password.Actions.sign_in(
|
||||
password_strategy,
|
||||
%{
|
||||
"email" => email,
|
||||
"password" => password
|
||||
},
|
||||
[]
|
||||
)
|
||||
else
|
||||
{:error, "Password authentication not configured"}
|
||||
end
|
||||
end
|
||||
|
||||
defp link_oidc_account(socket, user, oidc_user_info) do
|
||||
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
|
||||
|
||||
# Update the user with the OIDC ID
|
||||
case user.id
|
||||
|> reload_user!()
|
||||
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
||||
oidc_id: oidc_id,
|
||||
oidc_user_info: oidc_user_info
|
||||
})
|
||||
|> Ash.update() do
|
||||
{:ok, updated_user} ->
|
||||
# After successful linking, redirect to OIDC login
|
||||
# Since the user now has an oidc_id, the next OIDC login will succeed
|
||||
Logger.info(
|
||||
"OIDC account successfully linked after password verification: user_id=#{updated_user.id}, oidc_id=#{oidc_id}"
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(
|
||||
:info,
|
||||
dgettext(
|
||||
"auth",
|
||||
"Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
||||
)
|
||||
)
|
||||
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/rauthy")}
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning(
|
||||
"Failed to link OIDC account after password verification: user_id=#{user.id}, error=#{inspect(error)}"
|
||||
)
|
||||
|
||||
error_message = extract_user_friendly_error(error)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:error, error_message)
|
||||
|> reset_password_form()}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="mx-auto max-w-sm mt-16">
|
||||
<%!-- Language Selector --%>
|
||||
<nav aria-label={dgettext("auth", "Language selection")} class="flex justify-center mb-4">
|
||||
<form method="post" action="/set_locale" class="text-sm">
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<select
|
||||
name="locale"
|
||||
onchange="this.form.submit()"
|
||||
class="select select-sm select-bordered"
|
||||
aria-label={dgettext("auth", "Select language")}
|
||||
>
|
||||
<option value="de" selected={Gettext.get_locale() == "de"}>🇩🇪 Deutsch</option>
|
||||
<option value="en" selected={Gettext.get_locale() == "en"}>🇬🇧 English</option>
|
||||
</select>
|
||||
</form>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<.header class="text-center">
|
||||
{dgettext("auth", "Link OIDC Account")}
|
||||
<:subtitle>
|
||||
{dgettext(
|
||||
"auth",
|
||||
"An account with email %{email} already exists. Please enter your password to link your OIDC account.",
|
||||
email: @user.email
|
||||
)}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form for={@form} id="link-oidc-form" phx-submit="submit" phx-change="validate" class="mt-8">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<.input
|
||||
field={@form[:password]}
|
||||
type="password"
|
||||
label={dgettext("auth", "Password")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%= if @error do %>
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<p class="text-sm text-red-800">{@error}</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<.button phx-disable-with={dgettext("auth", "Linking...")} class="w-full">
|
||||
{dgettext("auth", "Link Account")}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
<div class="mt-4 text-center text-sm">
|
||||
<.link navigate={~p"/sign-in"} class="text-brand hover:underline">
|
||||
{dgettext("auth", "Cancel")}
|
||||
</.link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
146
lib/mv_web/live/components/payment_filter_component.ex
Normal file
146
lib/mv_web/live/components/payment_filter_component.ex
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
defmodule MvWeb.Components.PaymentFilterComponent do
|
||||
@moduledoc """
|
||||
Provides the PaymentFilter Live-Component.
|
||||
|
||||
A dropdown filter for filtering members by payment status (paid/not paid/all).
|
||||
Uses DaisyUI dropdown styling and sends filter changes to parent LiveView.
|
||||
|
||||
## Props
|
||||
- `:paid_filter` - Current filter state: `nil` (all), `:paid`, or `:not_paid`
|
||||
- `:id` - Component ID (required)
|
||||
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
|
||||
|
||||
## Events
|
||||
- Sends `{:payment_filter_changed, filter}` to parent when filter changes
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, :open, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:id, assigns.id)
|
||||
|> assign(:paid_filter, assigns[:paid_filter])
|
||||
|> assign(:member_count, assigns[:member_count] || 0)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class="relative"
|
||||
id={@id}
|
||||
phx-window-keydown={@open && "close_dropdown"}
|
||||
phx-key="Escape"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class={[
|
||||
"btn btn-ghost gap-2",
|
||||
@paid_filter && "btn-active"
|
||||
]}
|
||||
phx-click="toggle_dropdown"
|
||||
phx-target={@myself}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={to_string(@open)}
|
||||
aria-label={gettext("Filter by payment status")}
|
||||
>
|
||||
<.icon name="hero-funnel" class="h-5 w-5" />
|
||||
<span class="hidden sm:inline">{filter_label(@paid_filter)}</span>
|
||||
<span :if={@paid_filter} class="badge badge-primary badge-sm">{@member_count}</span>
|
||||
</button>
|
||||
|
||||
<ul
|
||||
:if={@open}
|
||||
class="menu dropdown-content bg-base-100 rounded-box z-10 w-52 p-2 shadow-lg absolute right-0 mt-2"
|
||||
role="menu"
|
||||
aria-label={gettext("Payment filter")}
|
||||
phx-click-away="close_dropdown"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={to_string(@paid_filter == nil)}
|
||||
class={@paid_filter == nil && "active"}
|
||||
phx-click="select_filter"
|
||||
phx-value-filter=""
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.icon name="hero-users" class="h-4 w-4" />
|
||||
{gettext("All")}
|
||||
</button>
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={to_string(@paid_filter == :paid)}
|
||||
class={@paid_filter == :paid && "active"}
|
||||
phx-click="select_filter"
|
||||
phx-value-filter="paid"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.icon name="hero-check-circle" class="h-4 w-4 text-success" />
|
||||
{gettext("Paid")}
|
||||
</button>
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={to_string(@paid_filter == :not_paid)}
|
||||
class={@paid_filter == :not_paid && "active"}
|
||||
phx-click="select_filter"
|
||||
phx-value-filter="not_paid"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.icon name="hero-x-circle" class="h-4 w-4 text-error" />
|
||||
{gettext("Not paid")}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, !socket.assigns.open)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("close_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("select_filter", %{"filter" => filter_str}, socket) do
|
||||
filter = parse_filter(filter_str)
|
||||
|
||||
# Close dropdown and notify parent
|
||||
socket = assign(socket, :open, false)
|
||||
send(self(), {:payment_filter_changed, filter})
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Parse filter string to atom
|
||||
defp parse_filter("paid"), do: :paid
|
||||
defp parse_filter("not_paid"), do: :not_paid
|
||||
defp parse_filter(_), do: nil
|
||||
|
||||
# Get display label for current filter
|
||||
defp filter_label(nil), do: gettext("All")
|
||||
defp filter_label(:paid), do: gettext("Paid")
|
||||
defp filter_label(:not_paid), do: gettext("Not paid")
|
||||
end
|
||||
|
|
@ -8,10 +8,10 @@ defmodule MvWeb.Components.SearchBarComponent do
|
|||
use MvWeb, :live_component
|
||||
|
||||
@impl true
|
||||
def update(_assigns, socket) do
|
||||
def update(%{query: query}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign_new(:query, fn -> "" end)
|
||||
|> assign_new(:query, fn -> query || "" end)
|
||||
|> assign_new(:placeholder, fn -> gettext("Search...") end)
|
||||
|
||||
{:ok, socket}
|
||||
|
|
@ -20,7 +20,7 @@ defmodule MvWeb.Components.SearchBarComponent do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<form phx-change="search" phx-target={@myself} class="flex" role="search" aria-label="Search">
|
||||
<form phx-submit="search" phx-target={@myself} class="flex" role="search" aria-label="Search">
|
||||
<label class="input">
|
||||
<svg
|
||||
class="h-[1em] opacity-50"
|
||||
|
|
@ -44,6 +44,9 @@ defmodule MvWeb.Components.SearchBarComponent do
|
|||
placeholder={@placeholder}
|
||||
value={@query}
|
||||
name="query"
|
||||
data-testid="search-input"
|
||||
phx-change="search"
|
||||
phx-target={@myself}
|
||||
phx-debounce="300"
|
||||
/>
|
||||
</label>
|
||||
|
|
|
|||
64
lib/mv_web/live/components/sort_header_component.ex
Normal file
64
lib/mv_web/live/components/sort_header_component.ex
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
defmodule MvWeb.Components.SortHeaderComponent do
|
||||
@moduledoc """
|
||||
Sort Header that can be used as column header and sorts a table:
|
||||
Props:
|
||||
- field: atom() # Ash Field for sorting
|
||||
- label: string() # Column Heading (can be an heex template)
|
||||
- sort_field: atom() | nil # current sort field from parent liveview
|
||||
- sort_order: :asc | :desc | nil # current sorting order
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
{:ok, assign(socket, assigns)}
|
||||
end
|
||||
|
||||
# Check if we can add the aria-sort label directly to the daisyUI header
|
||||
# aria-sort={aria_sort(@field, @sort_field, @sort_order)}
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="tooltip" data-tip={aria_sort(@field, @sort_field, @sort_order)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={aria_sort(@field, @sort_field, @sort_order)}
|
||||
class="btn btn-ghost select-none"
|
||||
phx-click="sort"
|
||||
phx-value-field={@field}
|
||||
phx-target={@myself}
|
||||
data-testid={@field}
|
||||
>
|
||||
{@label}
|
||||
<%= if @sort_field == @field do %>
|
||||
<.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
|
||||
<% else %>
|
||||
<.icon
|
||||
name="hero-chevron-up-down"
|
||||
class="opacity-40"
|
||||
/>
|
||||
<% end %>
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("sort", %{"field" => field_str}, socket) do
|
||||
send(self(), {:sort, field_str})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# -------------------------------------------------
|
||||
# Hilfsfunktionen für ARIA Attribute & Icon SVG
|
||||
# -------------------------------------------------
|
||||
defp aria_sort(field, sort_field, dir) when field == sort_field do
|
||||
case dir do
|
||||
:asc -> gettext("ascending")
|
||||
:desc -> gettext("descending")
|
||||
nil -> gettext("Click to sort")
|
||||
end
|
||||
end
|
||||
|
||||
defp aria_sort(_, _, _), do: gettext("Click to sort")
|
||||
end
|
||||
142
lib/mv_web/live/custom_field_live/form.ex
Normal file
142
lib/mv_web/live/custom_field_live/form.ex
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
defmodule MvWeb.CustomFieldLive.Form do
|
||||
@moduledoc """
|
||||
LiveView form for creating and editing custom fields (admin).
|
||||
|
||||
## Features
|
||||
- Create new custom field definitions
|
||||
- Edit existing custom fields
|
||||
- Select value type from supported types
|
||||
- Set immutable and required flags
|
||||
- Real-time validation
|
||||
|
||||
## Form Fields
|
||||
**Required:**
|
||||
- name - Unique identifier (e.g., "phone_mobile", "emergency_contact")
|
||||
- value_type - Data type (:string, :integer, :boolean, :date, :email)
|
||||
|
||||
**Optional:**
|
||||
- description - Human-readable explanation
|
||||
- immutable - If true, values cannot be changed after creation (default: false)
|
||||
- required - If true, all members must have this custom field (default: false)
|
||||
- show_in_overview - If true, this custom field will be displayed in the member overview table (default: true)
|
||||
|
||||
## Value Type Selection
|
||||
- `:string` - Text data (unlimited length)
|
||||
- `:integer` - Numeric data
|
||||
- `:boolean` - True/false flags
|
||||
- `:date` - Date values
|
||||
- `:email` - Validated email addresses
|
||||
|
||||
## Events
|
||||
- `validate` - Real-time form validation
|
||||
- `save` - Submit form (create or update custom field)
|
||||
|
||||
## Security
|
||||
Custom field management is restricted to admin users.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
{gettext("Use this form to manage custom_field records in your database.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form for={@form} id="custom_field-form" phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:name]} type="text" label={gettext("Name")} />
|
||||
|
||||
<.input
|
||||
field={@form[:value_type]}
|
||||
type="select"
|
||||
label={gettext("Value type")}
|
||||
options={
|
||||
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of]
|
||||
}
|
||||
/>
|
||||
<.input field={@form[:description]} type="text" label={gettext("Description")} />
|
||||
<.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} />
|
||||
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
|
||||
<.input field={@form[:show_in_overview]} type="checkbox" label={gettext("Show in overview")} />
|
||||
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
{gettext("Save Custom field")}
|
||||
</.button>
|
||||
<.button navigate={return_path(@return_to, @custom_field)}>{gettext("Cancel")}</.button>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
custom_field =
|
||||
case params["id"] do
|
||||
nil -> nil
|
||||
id -> Ash.get!(Mv.Membership.CustomField, id)
|
||||
end
|
||||
|
||||
action = if is_nil(custom_field), do: "New", else: "Edit"
|
||||
page_title = action <> " " <> "Custom field"
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:return_to, return_to(params["return_to"]))
|
||||
|> assign(custom_field: custom_field)
|
||||
|> assign(:page_title, page_title)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
defp return_to("show"), do: "show"
|
||||
defp return_to(_), do: "index"
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do
|
||||
{:noreply,
|
||||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: custom_field_params) do
|
||||
{:ok, custom_field} ->
|
||||
notify_parent({:saved, custom_field})
|
||||
|
||||
action =
|
||||
case socket.assigns.form.source.type do
|
||||
:create -> gettext("create")
|
||||
:update -> gettext("update")
|
||||
other -> to_string(other)
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:info, gettext("Custom field %{action} successfully", action: action))
|
||||
|> push_navigate(to: return_path(socket.assigns.return_to, custom_field))
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, form} ->
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
end
|
||||
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
|
||||
defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do
|
||||
form =
|
||||
if custom_field do
|
||||
AshPhoenix.Form.for_update(custom_field, :update, as: "custom_field")
|
||||
else
|
||||
AshPhoenix.Form.for_create(Mv.Membership.CustomField, :create, as: "custom_field")
|
||||
end
|
||||
|
||||
assign(socket, form: to_form(form))
|
||||
end
|
||||
|
||||
defp return_path("index", _custom_field), do: ~p"/custom_fields"
|
||||
defp return_path("show", custom_field), do: ~p"/custom_fields/#{custom_field.id}"
|
||||
end
|
||||
199
lib/mv_web/live/custom_field_live/index.ex
Normal file
199
lib/mv_web/live/custom_field_live/index.ex
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
defmodule MvWeb.CustomFieldLive.Index do
|
||||
@moduledoc """
|
||||
LiveView for managing custom field definitions (admin).
|
||||
|
||||
## Features
|
||||
- List all custom fields
|
||||
- Display type information (name, value type, description)
|
||||
- Show immutable and required flags
|
||||
- Create new custom fields
|
||||
- Edit existing custom fields
|
||||
- Delete custom fields with confirmation (cascades to all custom field values)
|
||||
|
||||
## Displayed Information
|
||||
- Name: Unique identifier for the custom field
|
||||
- Value type: Data type constraint (string, integer, boolean, date, email)
|
||||
- Description: Human-readable explanation
|
||||
- Immutable: Whether custom field values can be changed after creation
|
||||
- Required: Whether all members must have this custom field (future feature)
|
||||
|
||||
## Events
|
||||
- `prepare_delete` - Opens deletion confirmation modal with member count
|
||||
- `confirm_delete` - Executes deletion after slug verification
|
||||
- `cancel_delete` - Cancels deletion and closes modal
|
||||
- `update_slug_confirmation` - Updates slug input state
|
||||
|
||||
## Security
|
||||
Custom field management is restricted to admin users.
|
||||
Deletion requires entering the custom field's slug to prevent accidental deletions.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
Listing Custom fields
|
||||
<:actions>
|
||||
<.button variant="primary" navigate={~p"/custom_fields/new"}>
|
||||
<.icon name="hero-plus" /> New Custom field
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.table
|
||||
id="custom_fields"
|
||||
rows={@streams.custom_fields}
|
||||
row_click={fn {_id, custom_field} -> JS.navigate(~p"/custom_fields/#{custom_field}") end}
|
||||
>
|
||||
<:col :let={{_id, custom_field}} label="Name">{custom_field.name}</:col>
|
||||
|
||||
<:col :let={{_id, custom_field}} label="Description">{custom_field.description}</:col>
|
||||
|
||||
<:action :let={{_id, custom_field}}>
|
||||
<div class="sr-only">
|
||||
<.link navigate={~p"/custom_fields/#{custom_field}"}>Show</.link>
|
||||
</div>
|
||||
|
||||
<.link navigate={~p"/custom_fields/#{custom_field}/edit"}>Edit</.link>
|
||||
</:action>
|
||||
|
||||
<:action :let={{_id, custom_field}}>
|
||||
<.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id})}>
|
||||
Delete
|
||||
</.link>
|
||||
</:action>
|
||||
</.table>
|
||||
|
||||
<%!-- Delete Confirmation Modal --%>
|
||||
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">{gettext("Delete Custom Field")}</h3>
|
||||
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="alert alert-warning">
|
||||
<.icon name="hero-exclamation-triangle" class="h-5 w-5" />
|
||||
<div>
|
||||
<p class="font-semibold">
|
||||
{ngettext(
|
||||
"%{count} member has a value assigned for this custom field.",
|
||||
"%{count} members have values assigned for this custom field.",
|
||||
@custom_field_to_delete.assigned_members_count,
|
||||
count: @custom_field_to_delete.assigned_members_count
|
||||
)}
|
||||
</p>
|
||||
<p class="text-sm mt-2">
|
||||
{gettext(
|
||||
"All custom field values will be permanently deleted when you delete this custom field."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="slug-confirmation" class="label">
|
||||
<span class="label-text">
|
||||
{gettext("To confirm deletion, please enter this text:")}
|
||||
</span>
|
||||
</label>
|
||||
<div class="font-mono font-bold text-lg mb-2 p-2 bg-base-200 rounded break-all">
|
||||
{@custom_field_to_delete.slug}
|
||||
</div>
|
||||
<form phx-change="update_slug_confirmation">
|
||||
<input
|
||||
id="slug-confirmation"
|
||||
name="slug"
|
||||
type="text"
|
||||
value={@slug_confirmation}
|
||||
placeholder={gettext("Enter the text above to confirm")}
|
||||
autocomplete="off"
|
||||
phx-mounted={JS.focus()}
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button phx-click="cancel_delete" class="btn">
|
||||
{gettext("Cancel")}
|
||||
</button>
|
||||
<button
|
||||
phx-click="confirm_delete"
|
||||
class="btn btn-error"
|
||||
disabled={@slug_confirmation != @custom_field_to_delete.slug}
|
||||
>
|
||||
{gettext("Delete Custom Field and All Values")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Listing Custom fields")
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:custom_field_to_delete, nil)
|
||||
|> assign(:slug_confirmation, "")
|
||||
|> stream(:custom_fields, Ash.read!(Mv.Membership.CustomField))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("prepare_delete", %{"id" => id}, socket) do
|
||||
custom_field = Ash.get!(Mv.Membership.CustomField, id, load: [:assigned_members_count])
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:custom_field_to_delete, custom_field)
|
||||
|> assign(:show_delete_modal, true)
|
||||
|> assign(:slug_confirmation, "")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("update_slug_confirmation", %{"slug" => slug}, socket) do
|
||||
{:noreply, assign(socket, :slug_confirmation, slug)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("confirm_delete", _params, socket) do
|
||||
custom_field = socket.assigns.custom_field_to_delete
|
||||
|
||||
if socket.assigns.slug_confirmation == custom_field.slug do
|
||||
# Delete the custom field (CASCADE will handle custom field values)
|
||||
case Ash.destroy(custom_field) do
|
||||
:ok ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Custom field deleted successfully")
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:custom_field_to_delete, nil)
|
||||
|> assign(:slug_confirmation, "")
|
||||
|> stream_delete(:custom_fields, custom_field)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Failed to delete custom field: #{inspect(error)}")}
|
||||
end
|
||||
else
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Slug does not match. Deletion cancelled.")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_delete", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:custom_field_to_delete, nil)
|
||||
|> assign(:slug_confirmation, "")}
|
||||
end
|
||||
end
|
||||
75
lib/mv_web/live/custom_field_live/show.ex
Normal file
75
lib/mv_web/live/custom_field_live/show.ex
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
defmodule MvWeb.CustomFieldLive.Show do
|
||||
@moduledoc """
|
||||
LiveView for displaying a single custom field's details (admin).
|
||||
|
||||
## Features
|
||||
- Display custom field definition
|
||||
- Show all attributes (name, value type, description, flags)
|
||||
- Navigate to edit form
|
||||
- Return to custom field list
|
||||
|
||||
## Displayed Information
|
||||
- ID: Internal UUID identifier
|
||||
- Slug: URL-friendly identifier (auto-generated, immutable)
|
||||
- Name: Unique identifier
|
||||
- Value type: Data type constraint
|
||||
- Description: Optional explanation
|
||||
- Immutable flag: Whether values can be changed
|
||||
- Required flag: Whether all members need this custom field
|
||||
|
||||
## Navigation
|
||||
- Back to custom field list
|
||||
- Edit custom field
|
||||
|
||||
## Security
|
||||
Custom field details are restricted to admin users.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
Custom field {@custom_field.slug}
|
||||
<:subtitle>This is a custom_field record from your database.</:subtitle>
|
||||
|
||||
<:actions>
|
||||
<.button navigate={~p"/custom_fields"}>
|
||||
<.icon name="hero-arrow-left" />
|
||||
</.button>
|
||||
<.button
|
||||
variant="primary"
|
||||
navigate={~p"/custom_fields/#{@custom_field}/edit?return_to=show"}
|
||||
>
|
||||
<.icon name="hero-pencil-square" /> Edit Custom field
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.list>
|
||||
<:item title="Id">{@custom_field.id}</:item>
|
||||
|
||||
<:item title="Slug">
|
||||
{@custom_field.slug}
|
||||
<p class="mt-2 text-sm leading-6 text-zinc-600">
|
||||
{gettext("Auto-generated identifier (immutable)")}
|
||||
</p>
|
||||
</:item>
|
||||
|
||||
<:item title="Name">{@custom_field.name}</:item>
|
||||
|
||||
<:item title="Description">{@custom_field.description}</:item>
|
||||
</.list>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Show Custom field")
|
||||
|> assign(:custom_field, Ash.get!(Mv.Membership.CustomField, id))}
|
||||
end
|
||||
end
|
||||
|
|
@ -1,4 +1,35 @@
|
|||
defmodule MvWeb.PropertyLive.Form do
|
||||
defmodule MvWeb.CustomFieldValueLive.Form do
|
||||
@moduledoc """
|
||||
LiveView form for creating and editing custom field values.
|
||||
|
||||
## Features
|
||||
- Create new custom field values with member and type selection
|
||||
- Edit existing custom field values
|
||||
- Value input adapts to custom field type (string, integer, boolean, date, email)
|
||||
- Real-time validation
|
||||
|
||||
## Form Fields
|
||||
**Required:**
|
||||
- member - Select which member owns this custom field value
|
||||
- custom_field - Select the type (defines value type)
|
||||
- value - The actual value (input type depends on custom field type)
|
||||
|
||||
## Value Types
|
||||
The form dynamically renders appropriate inputs based on custom field type:
|
||||
- String: text input
|
||||
- Integer: number input
|
||||
- Boolean: checkbox
|
||||
- Date: date picker
|
||||
- Email: email input with validation
|
||||
|
||||
## Events
|
||||
- `validate` - Real-time form validation
|
||||
- `save` - Submit form (create or update custom field value)
|
||||
|
||||
## Note
|
||||
Custom field values are typically managed through the member edit form,
|
||||
not through this standalone form.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
|
|
@ -7,17 +38,19 @@ defmodule MvWeb.PropertyLive.Form do
|
|||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>{gettext("Use this form to manage property records in your database.")}</:subtitle>
|
||||
<:subtitle>
|
||||
{gettext("Use this form to manage Custom Field Value records in your database.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form for={@form} id="property-form" phx-change="validate" phx-submit="save">
|
||||
<!-- Property Type Selection -->
|
||||
<.form for={@form} id="custom_field_value-form" phx-change="validate" phx-submit="save">
|
||||
<!-- Custom Field Selection -->
|
||||
<.input
|
||||
field={@form[:property_type_id]}
|
||||
field={@form[:custom_field_id]}
|
||||
type="select"
|
||||
label={gettext("Property type")}
|
||||
options={property_type_options(@property_types)}
|
||||
prompt={gettext("Choose a property type")}
|
||||
label={gettext("Custom field")}
|
||||
options={custom_field_options(@custom_fields)}
|
||||
prompt={gettext("Choose a custom field")}
|
||||
/>
|
||||
|
||||
<!-- Member Selection -->
|
||||
|
|
@ -30,18 +63,18 @@ defmodule MvWeb.PropertyLive.Form do
|
|||
/>
|
||||
|
||||
<!-- Value Input - handles Union type -->
|
||||
<%= if @selected_property_type do %>
|
||||
<.union_value_input form={@form} property_type={@selected_property_type} />
|
||||
<%= if @selected_custom_field do %>
|
||||
<.union_value_input form={@form} custom_field={@selected_custom_field} />
|
||||
<% else %>
|
||||
<div class="text-sm text-gray-600">
|
||||
{gettext("Please select a property type first")}
|
||||
{gettext("Please select a custom field first")}
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
{gettext("Save Property")}
|
||||
{gettext("Save Custom field value")}
|
||||
</.button>
|
||||
<.button navigate={return_path(@return_to, @property)}>{gettext("Cancel")}</.button>
|
||||
<.button navigate={return_path(@return_to, @custom_field_value)}>{gettext("Cancel")}</.button>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
"""
|
||||
|
|
@ -49,8 +82,8 @@ defmodule MvWeb.PropertyLive.Form do
|
|||
|
||||
# Helper function for Union-Value Input
|
||||
defp union_value_input(assigns) do
|
||||
# Extract the current value from the Property
|
||||
current_value = extract_current_value(assigns.form.data, assigns.property_type.value_type)
|
||||
# Extract the current value from the CustomFieldValue
|
||||
current_value = extract_current_value(assigns.form.data, assigns.custom_field.value_type)
|
||||
assigns = assign(assigns, :current_value, current_value)
|
||||
|
||||
~H"""
|
||||
|
|
@ -59,7 +92,7 @@ defmodule MvWeb.PropertyLive.Form do
|
|||
{gettext("Value")}
|
||||
</label>
|
||||
|
||||
<%= case @property_type.value_type do %>
|
||||
<%= case @custom_field.value_type do %>
|
||||
<% :string -> %>
|
||||
<.inputs_for :let={value_form} field={@form[:value]}>
|
||||
<.input field={value_form[:value]} type="text" label="" value={@current_value} />
|
||||
|
|
@ -92,16 +125,16 @@ defmodule MvWeb.PropertyLive.Form do
|
|||
</.inputs_for>
|
||||
<% _ -> %>
|
||||
<div class="text-sm text-red-600">
|
||||
{gettext("Unsupported value type: %{type}", type: @property_type.value_type)}
|
||||
{gettext("Unsupported value type: %{type}", type: @custom_field.value_type)}
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Helper function to extract the current value from the Property
|
||||
# Helper function to extract the current value from the CustomFieldValue
|
||||
defp extract_current_value(
|
||||
%Mv.Membership.Property{value: %Ash.Union{value: value}},
|
||||
%Mv.Membership.CustomFieldValue{value: %Ash.Union{value: value}},
|
||||
_value_type
|
||||
) do
|
||||
value
|
||||
|
|
@ -129,27 +162,27 @@ defmodule MvWeb.PropertyLive.Form do
|
|||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
property =
|
||||
custom_field_value =
|
||||
case params["id"] do
|
||||
nil -> nil
|
||||
id -> Ash.get!(Mv.Membership.Property, id) |> Ash.load!([:property_type])
|
||||
id -> Ash.get!(Mv.Membership.CustomFieldValue, id) |> Ash.load!([:custom_field])
|
||||
end
|
||||
|
||||
action = if is_nil(property), do: "New", else: "Edit"
|
||||
page_title = action <> " " <> "Property"
|
||||
action = if is_nil(custom_field_value), do: "New", else: "Edit"
|
||||
page_title = action <> " " <> "Custom field value"
|
||||
|
||||
# Load all PropertyTypes and Members for the selection fields
|
||||
property_types = Ash.read!(Mv.Membership.PropertyType)
|
||||
# Load all CustomFields and Members for the selection fields
|
||||
custom_fields = Ash.read!(Mv.Membership.CustomField)
|
||||
members = Ash.read!(Mv.Membership.Member)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:return_to, return_to(params["return_to"]))
|
||||
|> assign(property: property)
|
||||
|> assign(custom_field_value: custom_field_value)
|
||||
|> assign(:page_title, page_title)
|
||||
|> assign(:property_types, property_types)
|
||||
|> assign(:custom_fields, custom_fields)
|
||||
|> assign(:members, members)
|
||||
|> assign(:selected_property_type, property && property.property_type)
|
||||
|> assign(:selected_custom_field, custom_field_value && custom_field_value.custom_field)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
|
|
@ -157,43 +190,43 @@ defmodule MvWeb.PropertyLive.Form do
|
|||
defp return_to(_), do: "index"
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"property" => property_params}, socket) do
|
||||
# Find the selected PropertyType
|
||||
selected_property_type =
|
||||
case property_params["property_type_id"] do
|
||||
def handle_event("validate", %{"custom_field_value" => custom_field_value_params}, socket) do
|
||||
# Find the selected CustomField
|
||||
selected_custom_field =
|
||||
case custom_field_value_params["custom_field_id"] do
|
||||
"" -> nil
|
||||
nil -> nil
|
||||
id -> Enum.find(socket.assigns.property_types, &(&1.id == id))
|
||||
id -> Enum.find(socket.assigns.custom_fields, &(&1.id == id))
|
||||
end
|
||||
|
||||
# Set the Union type based on the selected PropertyType
|
||||
# Set the Union type based on the selected CustomField
|
||||
updated_params =
|
||||
if selected_property_type do
|
||||
union_type = to_string(selected_property_type.value_type)
|
||||
put_in(property_params, ["value", "_union_type"], union_type)
|
||||
if selected_custom_field do
|
||||
union_type = to_string(selected_custom_field.value_type)
|
||||
put_in(custom_field_value_params, ["value", "_union_type"], union_type)
|
||||
else
|
||||
property_params
|
||||
custom_field_value_params
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:selected_property_type, selected_property_type)
|
||||
|> assign(:selected_custom_field, selected_custom_field)
|
||||
|> assign(form: AshPhoenix.Form.validate(socket.assigns.form, updated_params))}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"property" => property_params}, socket) do
|
||||
# Set the Union type based on the selected PropertyType
|
||||
def handle_event("save", %{"custom_field_value" => custom_field_value_params}, socket) do
|
||||
# Set the Union type based on the selected CustomField
|
||||
updated_params =
|
||||
if socket.assigns.selected_property_type do
|
||||
union_type = to_string(socket.assigns.selected_property_type.value_type)
|
||||
put_in(property_params, ["value", "_union_type"], union_type)
|
||||
if socket.assigns.selected_custom_field do
|
||||
union_type = to_string(socket.assigns.selected_custom_field.value_type)
|
||||
put_in(custom_field_value_params, ["value", "_union_type"], union_type)
|
||||
else
|
||||
property_params
|
||||
custom_field_value_params
|
||||
end
|
||||
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: updated_params) do
|
||||
{:ok, property} ->
|
||||
notify_parent({:saved, property})
|
||||
{:ok, custom_field_value} ->
|
||||
notify_parent({:saved, custom_field_value})
|
||||
|
||||
action =
|
||||
case socket.assigns.form.source.type do
|
||||
|
|
@ -204,8 +237,11 @@ defmodule MvWeb.PropertyLive.Form do
|
|||
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:info, gettext("Property %{action} successfully", action: action))
|
||||
|> push_navigate(to: return_path(socket.assigns.return_to, property))
|
||||
|> put_flash(
|
||||
:info,
|
||||
gettext("Custom field value %{action} successfully", action: action)
|
||||
)
|
||||
|> push_navigate(to: return_path(socket.assigns.return_to, custom_field_value))
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
|
|
@ -216,11 +252,11 @@ defmodule MvWeb.PropertyLive.Form do
|
|||
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
|
||||
defp assign_form(%{assigns: %{property: property}} = socket) do
|
||||
defp assign_form(%{assigns: %{custom_field_value: custom_field_value}} = socket) do
|
||||
form =
|
||||
if property do
|
||||
# Determine the Union type based on the property_type
|
||||
union_type = property.property_type && property.property_type.value_type
|
||||
if custom_field_value do
|
||||
# Determine the Union type based on the custom_field
|
||||
union_type = custom_field_value.custom_field && custom_field_value.custom_field.value_type
|
||||
|
||||
params =
|
||||
if union_type do
|
||||
|
|
@ -229,20 +265,27 @@ defmodule MvWeb.PropertyLive.Form do
|
|||
%{}
|
||||
end
|
||||
|
||||
AshPhoenix.Form.for_update(property, :update, as: "property", params: params)
|
||||
AshPhoenix.Form.for_update(custom_field_value, :update,
|
||||
as: "custom_field_value",
|
||||
params: params
|
||||
)
|
||||
else
|
||||
AshPhoenix.Form.for_create(Mv.Membership.Property, :create, as: "property")
|
||||
AshPhoenix.Form.for_create(Mv.Membership.CustomFieldValue, :create,
|
||||
as: "custom_field_value"
|
||||
)
|
||||
end
|
||||
|
||||
assign(socket, form: to_form(form))
|
||||
end
|
||||
|
||||
defp return_path("index", _property), do: ~p"/properties"
|
||||
defp return_path("show", property), do: ~p"/properties/#{property.id}"
|
||||
defp return_path("index", _custom_field_value), do: ~p"/custom_field_values"
|
||||
|
||||
defp return_path("show", custom_field_value),
|
||||
do: ~p"/custom_field_values/#{custom_field_value.id}"
|
||||
|
||||
# Helper functions for selection options
|
||||
defp property_type_options(property_types) do
|
||||
Enum.map(property_types, &{&1.name, &1.id})
|
||||
defp custom_field_options(custom_fields) do
|
||||
Enum.map(custom_fields, &{&1.name, &1.id})
|
||||
end
|
||||
|
||||
defp member_options(members) do
|
||||
86
lib/mv_web/live/custom_field_value_live/index.ex
Normal file
86
lib/mv_web/live/custom_field_value_live/index.ex
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
defmodule MvWeb.CustomFieldValueLive.Index do
|
||||
@moduledoc """
|
||||
LiveView for displaying and managing custom field values.
|
||||
|
||||
## Features
|
||||
- List all custom field values with their values and types
|
||||
- Show which member each custom field value belongs to
|
||||
- Display custom field information
|
||||
- Navigate to custom field value details and edit forms
|
||||
- Delete custom field values
|
||||
|
||||
## Relationships
|
||||
Each custom field value is linked to:
|
||||
- A member (the custom field value owner)
|
||||
- A custom field (defining value type and behavior)
|
||||
|
||||
## Events
|
||||
- `delete` - Remove a custom field value from the database
|
||||
|
||||
## Note
|
||||
Custom field values are typically managed through the member edit form.
|
||||
This view provides a global overview of all custom field values.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
Listing Custom field values
|
||||
<:actions>
|
||||
<.button variant="primary" navigate={~p"/custom_field_values/new"}>
|
||||
<.icon name="hero-plus" /> New Custom field value
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.table
|
||||
id="custom_field_values"
|
||||
rows={@streams.custom_field_values}
|
||||
row_click={
|
||||
fn {_id, custom_field_value} ->
|
||||
JS.navigate(~p"/custom_field_values/#{custom_field_value}")
|
||||
end
|
||||
}
|
||||
>
|
||||
<:col :let={{_id, custom_field_value}} label="Id">{custom_field_value.id}</:col>
|
||||
|
||||
<:action :let={{_id, custom_field_value}}>
|
||||
<div class="sr-only">
|
||||
<.link navigate={~p"/custom_field_values/#{custom_field_value}"}>Show</.link>
|
||||
</div>
|
||||
|
||||
<.link navigate={~p"/custom_field_values/#{custom_field_value}/edit"}>Edit</.link>
|
||||
</:action>
|
||||
|
||||
<:action :let={{id, custom_field_value}}>
|
||||
<.link
|
||||
phx-click={JS.push("delete", value: %{id: custom_field_value.id}) |> hide("##{id}")}
|
||||
data-confirm="Are you sure?"
|
||||
>
|
||||
Delete
|
||||
</.link>
|
||||
</:action>
|
||||
</.table>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Listing Custom field values")
|
||||
|> stream(:custom_field_values, Ash.read!(Mv.Membership.CustomFieldValue))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
custom_field_value = Ash.get!(Mv.Membership.CustomFieldValue, id)
|
||||
Ash.destroy!(custom_field_value)
|
||||
|
||||
{:noreply, stream_delete(socket, :custom_field_values, custom_field_value)}
|
||||
end
|
||||
end
|
||||
67
lib/mv_web/live/custom_field_value_live/show.ex
Normal file
67
lib/mv_web/live/custom_field_value_live/show.ex
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
defmodule MvWeb.CustomFieldValueLive.Show do
|
||||
@moduledoc """
|
||||
LiveView for displaying a single custom field value's details.
|
||||
|
||||
## Features
|
||||
- Display custom field value and type
|
||||
- Show linked member
|
||||
- Show custom field definition
|
||||
- Navigate to edit form
|
||||
- Return to custom field value list
|
||||
|
||||
## Displayed Information
|
||||
- Custom field value (formatted based on type)
|
||||
- Custom field name and description
|
||||
- Member information (who owns this custom field value)
|
||||
- Custom field value metadata (ID, timestamps if added)
|
||||
|
||||
## Navigation
|
||||
- Back to custom field value list
|
||||
- Edit custom field value
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
Custom field value {@custom_field_value.id}
|
||||
<:subtitle>This is a custom_field_value record from your database.</:subtitle>
|
||||
|
||||
<:actions>
|
||||
<.button navigate={~p"/custom_field_values"}>
|
||||
<.icon name="hero-arrow-left" />
|
||||
</.button>
|
||||
<.button
|
||||
variant="primary"
|
||||
navigate={~p"/custom_field_values/#{@custom_field_value}/edit?return_to=show"}
|
||||
>
|
||||
<.icon name="hero-pencil-square" /> Edit Custom field value
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.list>
|
||||
<:item title="Id">{@custom_field_value.id}</:item>
|
||||
</.list>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"id" => id}, _, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, page_title(socket.assigns.live_action))
|
||||
|> assign(:custom_field_value, Ash.get!(Mv.Membership.CustomFieldValue, id))}
|
||||
end
|
||||
|
||||
defp page_title(:show), do: "Show Custom field value"
|
||||
defp page_title(:edit), do: "Edit Custom field value"
|
||||
end
|
||||
97
lib/mv_web/live/global_settings_live.ex
Normal file
97
lib/mv_web/live/global_settings_live.ex
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
defmodule MvWeb.GlobalSettingsLive do
|
||||
@moduledoc """
|
||||
LiveView for managing global application settings (Vereinsdaten).
|
||||
|
||||
## Features
|
||||
- Edit the association/club name
|
||||
- Real-time form validation
|
||||
- Success/error feedback
|
||||
|
||||
## Settings
|
||||
- `club_name` - The name of the association/club (required)
|
||||
|
||||
## Events
|
||||
- `validate` - Real-time form validation
|
||||
- `save` - Save settings changes
|
||||
|
||||
## Note
|
||||
Settings is a singleton resource - there is only one settings record.
|
||||
The club_name can also be set via the `ASSOCIATION_NAME` environment variable.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
alias Mv.Membership
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Club Settings"))
|
||||
|> assign(:settings, settings)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Club Settings")}
|
||||
<:subtitle>
|
||||
{gettext("Manage global settings for the association.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
|
||||
<.input
|
||||
field={@form[:club_name]}
|
||||
type="text"
|
||||
label={gettext("Association Name")}
|
||||
required
|
||||
/>
|
||||
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
{gettext("Save Settings")}
|
||||
</.button>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"setting" => setting_params}, socket) do
|
||||
{:noreply,
|
||||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"setting" => setting_params}, socket) do
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do
|
||||
{:ok, updated_settings} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:settings, updated_settings)
|
||||
|> put_flash(:info, gettext("Settings updated successfully"))
|
||||
|> assign_form()
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, form} ->
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||
form =
|
||||
AshPhoenix.Form.for_update(
|
||||
settings,
|
||||
:update,
|
||||
api: Membership,
|
||||
as: "setting",
|
||||
forms: [auto?: true]
|
||||
)
|
||||
|
||||
assign(socket, form: to_form(form))
|
||||
end
|
||||
end
|
||||
|
|
@ -1,4 +1,33 @@
|
|||
defmodule MvWeb.MemberLive.Form do
|
||||
@moduledoc """
|
||||
LiveView form for creating and editing members.
|
||||
|
||||
## Features
|
||||
- Create new members with personal information
|
||||
- Edit existing member details
|
||||
- Manage custom properties (dynamic fields)
|
||||
- Real-time validation with visual feedback
|
||||
- Link/unlink user accounts
|
||||
|
||||
## Form Fields
|
||||
**Required:**
|
||||
- first_name, last_name, email
|
||||
|
||||
**Optional:**
|
||||
- phone_number, address fields (city, street, house_number, postal_code)
|
||||
- join_date, exit_date
|
||||
- paid status
|
||||
- notes
|
||||
|
||||
## Custom Field Values
|
||||
Members can have dynamic custom field values defined by CustomFields.
|
||||
The form dynamically renders inputs based on available CustomFields.
|
||||
|
||||
## Events
|
||||
- `validate` - Real-time form validation
|
||||
- `save` - Submit form (create or update member)
|
||||
- Custom field value management events for adding/removing custom fields
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
|
|
@ -8,7 +37,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
{gettext("Use this form to manage member records and their properties.")}
|
||||
{gettext("Fields marked with an asterisk (*) cannot be empty.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
|
|
@ -16,7 +45,6 @@ defmodule MvWeb.MemberLive.Form do
|
|||
<.input field={@form[:first_name]} label={gettext("First Name")} required />
|
||||
<.input field={@form[:last_name]} label={gettext("Last Name")} required />
|
||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||
<.input field={@form[:birth_date]} label={gettext("Birth Date")} type="date" />
|
||||
<.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" />
|
||||
<.input field={@form[:phone_number]} label={gettext("Phone Number")} />
|
||||
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />
|
||||
|
|
@ -27,10 +55,11 @@ defmodule MvWeb.MemberLive.Form do
|
|||
<.input field={@form[:house_number]} label={gettext("House Number")} />
|
||||
<.input field={@form[:postal_code]} label={gettext("Postal Code")} />
|
||||
|
||||
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Properties")}</h3>
|
||||
<.inputs_for :let={f_property} field={@form[:properties]}>
|
||||
<% type = Enum.find(@property_types, &(&1.id == f_property[:property_type_id].value)) %>
|
||||
<.inputs_for :let={value_form} field={f_property[:value]}>
|
||||
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Field Values")}</h3>
|
||||
<.inputs_for :let={f_custom_field_value} field={@form[:custom_field_values]}>
|
||||
<% type =
|
||||
Enum.find(@custom_fields, &(&1.id == f_custom_field_value[:custom_field_id].value)) %>
|
||||
<.inputs_for :let={value_form} field={f_custom_field_value[:value]}>
|
||||
<% input_type =
|
||||
cond do
|
||||
type && type.value_type == :boolean -> "checkbox"
|
||||
|
|
@ -41,8 +70,8 @@ defmodule MvWeb.MemberLive.Form do
|
|||
</.inputs_for>
|
||||
<input
|
||||
type="hidden"
|
||||
name={f_property[:property_type_id].name}
|
||||
value={f_property[:property_type_id].value}
|
||||
name={f_custom_field_value[:custom_field_id].name}
|
||||
value={f_custom_field_value[:custom_field_id].value}
|
||||
/>
|
||||
</.inputs_for>
|
||||
|
||||
|
|
@ -57,16 +86,16 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
{:ok, property_types} = Mv.Membership.list_property_types()
|
||||
{:ok, custom_fields} = Mv.Membership.list_custom_fields()
|
||||
|
||||
initial_properties =
|
||||
Enum.map(property_types, fn pt ->
|
||||
initial_custom_field_values =
|
||||
Enum.map(custom_fields, fn cf ->
|
||||
%{
|
||||
"property_type_id" => pt.id,
|
||||
"custom_field_id" => cf.id,
|
||||
"value" => %{
|
||||
"type" => pt.value_type,
|
||||
"type" => cf.value_type,
|
||||
"value" => nil,
|
||||
"_union_type" => Atom.to_string(pt.value_type)
|
||||
"_union_type" => Atom.to_string(cf.value_type)
|
||||
}
|
||||
}
|
||||
end)
|
||||
|
|
@ -83,8 +112,8 @@ defmodule MvWeb.MemberLive.Form do
|
|||
{:ok,
|
||||
socket
|
||||
|> assign(:return_to, return_to(params["return_to"]))
|
||||
|> assign(:property_types, property_types)
|
||||
|> assign(:initial_properties, initial_properties)
|
||||
|> assign(:custom_fields, custom_fields)
|
||||
|> assign(:initial_custom_field_values, initial_custom_field_values)
|
||||
|> assign(member: member)
|
||||
|> assign(:page_title, page_title)
|
||||
|> assign_form()}
|
||||
|
|
@ -127,25 +156,25 @@ defmodule MvWeb.MemberLive.Form do
|
|||
defp assign_form(%{assigns: %{member: member}} = socket) do
|
||||
form =
|
||||
if member do
|
||||
{:ok, member} = Ash.load(member, properties: [:property_type])
|
||||
{:ok, member} = Ash.load(member, custom_field_values: [:custom_field])
|
||||
|
||||
existing_properties =
|
||||
member.properties
|
||||
|> Enum.map(& &1.property_type_id)
|
||||
existing_custom_field_values =
|
||||
member.custom_field_values
|
||||
|> Enum.map(& &1.custom_field_id)
|
||||
|
||||
is_missing_property = fn i ->
|
||||
not Enum.member?(existing_properties, Map.get(i, "property_type_id"))
|
||||
is_missing_custom_field_value = fn i ->
|
||||
not Enum.member?(existing_custom_field_values, Map.get(i, "custom_field_id"))
|
||||
end
|
||||
|
||||
params = %{
|
||||
"properties" =>
|
||||
Enum.map(member.properties, fn prop ->
|
||||
"custom_field_values" =>
|
||||
Enum.map(member.custom_field_values, fn cfv ->
|
||||
%{
|
||||
"property_type_id" => prop.property_type_id,
|
||||
"custom_field_id" => cfv.custom_field_id,
|
||||
"value" => %{
|
||||
"_union_type" => Atom.to_string(prop.value.type),
|
||||
"type" => prop.value.type,
|
||||
"value" => prop.value.value
|
||||
"_union_type" => Atom.to_string(cfv.value.type),
|
||||
"type" => cfv.value.type,
|
||||
"value" => cfv.value.value
|
||||
}
|
||||
}
|
||||
end)
|
||||
|
|
@ -161,12 +190,13 @@ defmodule MvWeb.MemberLive.Form do
|
|||
forms: [auto?: true]
|
||||
)
|
||||
|
||||
missing_properties = Enum.filter(socket.assigns[:initial_properties], is_missing_property)
|
||||
missing_custom_field_values =
|
||||
Enum.filter(socket.assigns[:initial_custom_field_values], is_missing_custom_field_value)
|
||||
|
||||
Enum.reduce(
|
||||
missing_properties,
|
||||
missing_custom_field_values,
|
||||
form,
|
||||
&AshPhoenix.Form.add_form(&2, [:properties], params: &1)
|
||||
&AshPhoenix.Form.add_form(&2, [:custom_field_values], params: &1)
|
||||
)
|
||||
else
|
||||
AshPhoenix.Form.for_create(
|
||||
|
|
@ -174,7 +204,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
:create_member,
|
||||
api: Mv.Membership,
|
||||
as: "member",
|
||||
params: %{"properties" => socket.assigns[:initial_properties]},
|
||||
params: %{"custom_field_values" => socket.assigns[:initial_custom_field_values]},
|
||||
forms: [auto?: true]
|
||||
)
|
||||
end
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,23 +2,57 @@
|
|||
<.header>
|
||||
{gettext("Members")}
|
||||
<:actions>
|
||||
<.button
|
||||
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
|
||||
id="copy-emails-btn"
|
||||
phx-hook="CopyToClipboard"
|
||||
phx-click="copy_emails"
|
||||
aria-label={gettext("Copy email addresses of selected members")}
|
||||
>
|
||||
<.icon name="hero-clipboard-document" />
|
||||
{gettext("Copy emails")} ({Enum.count(@members, &MapSet.member?(@selected_members, &1.id))})
|
||||
</.button>
|
||||
<.button
|
||||
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
|
||||
href={
|
||||
"mailto:?bcc=" <>
|
||||
(MvWeb.MemberLive.Index.format_selected_member_emails(@members, @selected_members)
|
||||
|> Enum.join(", ")
|
||||
|> URI.encode())
|
||||
}
|
||||
aria-label={gettext("Open email program with BCC recipients")}
|
||||
>
|
||||
<.icon name="hero-envelope" />
|
||||
{gettext("Open in email program")}
|
||||
</.button>
|
||||
<.button variant="primary" navigate={~p"/members/new"}>
|
||||
<.icon name="hero-plus" /> {gettext("New Member")}
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<.live_component
|
||||
module={MvWeb.Components.SearchBarComponent}
|
||||
id="search-bar"
|
||||
query={@query}
|
||||
placeholder={gettext("Search...")}
|
||||
/>
|
||||
<.live_component
|
||||
module={MvWeb.Components.PaymentFilterComponent}
|
||||
id="payment-filter"
|
||||
paid_filter={@paid_filter}
|
||||
member_count={length(@members)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<.table
|
||||
id="members"
|
||||
rows={@members}
|
||||
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
|
||||
dynamic_cols={@dynamic_cols}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
>
|
||||
|
||||
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
|
||||
|
|
@ -30,7 +64,7 @@
|
|||
type="checkbox"
|
||||
name="select_all"
|
||||
phx-click="select_all"
|
||||
checked={Enum.sort(@selected_members) == Enum.map(@members, & &1.id) |> Enum.sort()}
|
||||
checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())}
|
||||
aria-label={gettext("Select all members")}
|
||||
role="checkbox"
|
||||
/>
|
||||
|
|
@ -42,7 +76,7 @@
|
|||
name={member.id}
|
||||
phx-click="select_member"
|
||||
phx-value-id={member.id}
|
||||
checked={member.id in @selected_members}
|
||||
checked={MapSet.member?(@selected_members, member.id)}
|
||||
phx-capture-click
|
||||
phx-stop-propagation
|
||||
aria-label={gettext("Select member")}
|
||||
|
|
@ -52,24 +86,154 @@
|
|||
<:col
|
||||
:let={member}
|
||||
label={
|
||||
sort_button(%{
|
||||
field: :first_name,
|
||||
label: gettext("Name"),
|
||||
sort_field: @sort_field,
|
||||
sort_order: @sort_order
|
||||
})
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_first_name}
|
||||
field={:first_name}
|
||||
label={gettext("First name")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.first_name} {member.last_name}
|
||||
</:col>
|
||||
<:col :let={member} label={gettext("Email")}>{member.email}</:col>
|
||||
<:col :let={member} label={gettext("Street")}>{member.street}</:col>
|
||||
<:col :let={member} label={gettext("House Number")}>{member.house_number}</:col>
|
||||
<:col :let={member} label={gettext("Postal Code")}>{member.postal_code}</:col>
|
||||
<:col :let={member} label={gettext("City")}>{member.city}</:col>
|
||||
<:col :let={member} label={gettext("Phone Number")}>{member.phone_number}</:col>
|
||||
<:col :let={member} label={gettext("Join Date")}>{member.join_date}</:col>
|
||||
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:email in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_email}
|
||||
field={:email}
|
||||
label={gettext("Email")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.email}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:street in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_street}
|
||||
field={:street}
|
||||
label={gettext("Street")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.street}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:house_number in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_house_number}
|
||||
field={:house_number}
|
||||
label={gettext("House Number")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.house_number}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:postal_code in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_postal_code}
|
||||
field={:postal_code}
|
||||
label={gettext("Postal Code")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.postal_code}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:city in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_city}
|
||||
field={:city}
|
||||
label={gettext("City")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.city}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:phone_number in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_phone_number}
|
||||
field={:phone_number}
|
||||
label={gettext("Phone Number")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.phone_number}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:join_date in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_join_date}
|
||||
field={:join_date}
|
||||
label={gettext("Join Date")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.join_date}
|
||||
</:col>
|
||||
<:col :let={member} label={gettext("Paid")}>
|
||||
<span class={[
|
||||
"badge",
|
||||
if(member.paid == true, do: "badge-success", else: "badge-error")
|
||||
]}>
|
||||
{if member.paid == true, do: gettext("Yes"), else: gettext("No")}
|
||||
</span>
|
||||
</:col>
|
||||
<:action :let={member}>
|
||||
<div class="sr-only">
|
||||
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
|
||||
|
|
|
|||
74
lib/mv_web/live/member_live/index/formatter.ex
Normal file
74
lib/mv_web/live/member_live/index/formatter.ex
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
defmodule MvWeb.MemberLive.Index.Formatter do
|
||||
@moduledoc """
|
||||
Formats custom field values for display in the member overview table.
|
||||
|
||||
Handles different value types (string, integer, boolean, date, email) and
|
||||
formats them appropriately for display in the UI.
|
||||
"""
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
@doc """
|
||||
Formats a custom field value for display.
|
||||
|
||||
Handles different input formats:
|
||||
- `nil` - Returns empty string
|
||||
- `%Ash.Union{}` - Extracts value and type from union type
|
||||
- Map (JSONB format) - Extracts type and value from map keys
|
||||
- Direct value - Uses custom_field.value_type to determine format
|
||||
|
||||
## Examples
|
||||
|
||||
iex> format_custom_field_value(nil, %CustomField{value_type: :string})
|
||||
""
|
||||
|
||||
iex> format_custom_field_value("test", %CustomField{value_type: :string})
|
||||
"test"
|
||||
|
||||
iex> format_custom_field_value(true, %CustomField{value_type: :boolean})
|
||||
"Yes"
|
||||
"""
|
||||
def format_custom_field_value(nil, _custom_field), do: ""
|
||||
|
||||
def format_custom_field_value(%Ash.Union{value: value, type: type}, custom_field) do
|
||||
format_value_by_type(value, type, custom_field)
|
||||
end
|
||||
|
||||
def format_custom_field_value(value, custom_field) when is_map(value) do
|
||||
# Handle map format from JSONB
|
||||
type = Map.get(value, "type") || Map.get(value, "_union_type")
|
||||
val = Map.get(value, "value") || Map.get(value, "_union_value")
|
||||
format_value_by_type(val, type, custom_field)
|
||||
end
|
||||
|
||||
def format_custom_field_value(value, custom_field) do
|
||||
format_value_by_type(value, custom_field.value_type, custom_field)
|
||||
end
|
||||
|
||||
# Format value based on type
|
||||
|
||||
defp format_value_by_type(value, :string, _), do: to_string(value)
|
||||
|
||||
defp format_value_by_type(value, :integer, _), do: to_string(value)
|
||||
|
||||
defp format_value_by_type(value, type, _) when type in [:string, :email] and is_binary(value) do
|
||||
# Return empty string if value is empty
|
||||
if String.trim(value) == "", do: "", else: value
|
||||
end
|
||||
|
||||
defp format_value_by_type(value, :email, _), do: to_string(value)
|
||||
|
||||
defp format_value_by_type(value, :boolean, _) when value == true, do: gettext("Yes")
|
||||
defp format_value_by_type(value, :boolean, _) when value == false, do: gettext("No")
|
||||
defp format_value_by_type(value, :boolean, _), do: to_string(value)
|
||||
|
||||
defp format_value_by_type(%Date{} = date, :date, _), do: Date.to_string(date)
|
||||
|
||||
defp format_value_by_type(value, :date, _) when is_binary(value) do
|
||||
case Date.from_iso8601(value) do
|
||||
{:ok, date} -> Date.to_string(date)
|
||||
_ -> value
|
||||
end
|
||||
end
|
||||
|
||||
defp format_value_by_type(value, _type, _), do: to_string(value)
|
||||
end
|
||||
|
|
@ -1,4 +1,26 @@
|
|||
defmodule MvWeb.MemberLive.Show do
|
||||
@moduledoc """
|
||||
LiveView for displaying a single member's details.
|
||||
|
||||
## Features
|
||||
- Display all member information (personal, contact, address)
|
||||
- Show linked user account (if exists)
|
||||
- Display custom field values
|
||||
- Navigate to edit form
|
||||
- Return to member list
|
||||
|
||||
## Displayed Information
|
||||
- Basic: name, email, dates (join, exit)
|
||||
- Contact: phone number
|
||||
- Address: street, house number, postal code, city
|
||||
- Status: paid flag
|
||||
- Relationships: linked user account
|
||||
- Custom: dynamic custom field values from CustomFields
|
||||
|
||||
## Navigation
|
||||
- Back to member list
|
||||
- Edit member (with return_to parameter for back navigation)
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
import Ash.Query
|
||||
|
||||
|
|
@ -26,7 +48,6 @@ defmodule MvWeb.MemberLive.Show do
|
|||
<:item title={gettext("First Name")}>{@member.first_name}</:item>
|
||||
<:item title={gettext("Last Name")}>{@member.last_name}</:item>
|
||||
<:item title={gettext("Email")}>{@member.email}</:item>
|
||||
<:item title={gettext("Birth Date")}>{@member.birth_date}</:item>
|
||||
<:item title={gettext("Paid")}>
|
||||
{if @member.paid, do: gettext("Yes"), else: gettext("No")}
|
||||
</:item>
|
||||
|
|
@ -53,14 +74,14 @@ defmodule MvWeb.MemberLive.Show do
|
|||
</:item>
|
||||
</.list>
|
||||
|
||||
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Properties")}</h3>
|
||||
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Field Values")}</h3>
|
||||
<.generic_list items={
|
||||
Enum.map(@member.properties, fn p ->
|
||||
Enum.map(@member.custom_field_values, fn cfv ->
|
||||
{
|
||||
# name
|
||||
p.property_type && p.property_type.name,
|
||||
cfv.custom_field && cfv.custom_field.name,
|
||||
# value
|
||||
case p.value do
|
||||
case cfv.value do
|
||||
%{value: v} -> v
|
||||
v -> v
|
||||
end
|
||||
|
|
@ -81,7 +102,7 @@ defmodule MvWeb.MemberLive.Show do
|
|||
query =
|
||||
Mv.Membership.Member
|
||||
|> filter(id == ^id)
|
||||
|> load([:user, properties: [:property_type]])
|
||||
|> load([:user, custom_field_values: [:custom_field]])
|
||||
|
||||
member = Ash.read_one!(query)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
defmodule MvWeb.PropertyLive.Index do
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
Listing Properties
|
||||
<:actions>
|
||||
<.button variant="primary" navigate={~p"/properties/new"}>
|
||||
<.icon name="hero-plus" /> New Property
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.table
|
||||
id="properties"
|
||||
rows={@streams.properties}
|
||||
row_click={fn {_id, property} -> JS.navigate(~p"/properties/#{property}") end}
|
||||
>
|
||||
<:col :let={{_id, property}} label="Id">{property.id}</:col>
|
||||
|
||||
<:action :let={{_id, property}}>
|
||||
<div class="sr-only">
|
||||
<.link navigate={~p"/properties/#{property}"}>Show</.link>
|
||||
</div>
|
||||
|
||||
<.link navigate={~p"/properties/#{property}/edit"}>Edit</.link>
|
||||
</:action>
|
||||
|
||||
<:action :let={{id, property}}>
|
||||
<.link
|
||||
phx-click={JS.push("delete", value: %{id: property.id}) |> hide("##{id}")}
|
||||
data-confirm="Are you sure?"
|
||||
>
|
||||
Delete
|
||||
</.link>
|
||||
</:action>
|
||||
</.table>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Listing Properties")
|
||||
|> stream(:properties, Ash.read!(Mv.Membership.Property))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
property = Ash.get!(Mv.Membership.Property, id)
|
||||
Ash.destroy!(property)
|
||||
|
||||
{:noreply, stream_delete(socket, :properties, property)}
|
||||
end
|
||||
end
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
defmodule MvWeb.PropertyLive.Show do
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
Property {@property.id}
|
||||
<:subtitle>This is a property record from your database.</:subtitle>
|
||||
|
||||
<:actions>
|
||||
<.button navigate={~p"/properties"}>
|
||||
<.icon name="hero-arrow-left" />
|
||||
</.button>
|
||||
<.button variant="primary" navigate={~p"/properties/#{@property}/edit?return_to=show"}>
|
||||
<.icon name="hero-pencil-square" /> Edit Property
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.list>
|
||||
<:item title="Id">{@property.id}</:item>
|
||||
</.list>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"id" => id}, _, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, page_title(socket.assigns.live_action))
|
||||
|> assign(:property, Ash.get!(Mv.Membership.Property, id))}
|
||||
end
|
||||
|
||||
defp page_title(:show), do: "Show Property"
|
||||
defp page_title(:edit), do: "Edit Property"
|
||||
end
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
defmodule MvWeb.PropertyTypeLive.Form do
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
{gettext("Use this form to manage property_type records in your database.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form for={@form} id="property_type-form" phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:name]} type="text" label={gettext("Name")} />
|
||||
<.input
|
||||
field={@form[:value_type]}
|
||||
type="select"
|
||||
label={gettext("Value type")}
|
||||
options={
|
||||
Ash.Resource.Info.attribute(Mv.Membership.PropertyType, :value_type).constraints[:one_of]
|
||||
}
|
||||
/>
|
||||
<.input field={@form[:description]} type="text" label={gettext("Description")} />
|
||||
<.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} />
|
||||
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
|
||||
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
{gettext("Save Property type")}
|
||||
</.button>
|
||||
<.button navigate={return_path(@return_to, @property_type)}>{gettext("Cancel")}</.button>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
property_type =
|
||||
case params["id"] do
|
||||
nil -> nil
|
||||
id -> Ash.get!(Mv.Membership.PropertyType, id)
|
||||
end
|
||||
|
||||
action = if is_nil(property_type), do: "New", else: "Edit"
|
||||
page_title = action <> " " <> "Property type"
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:return_to, return_to(params["return_to"]))
|
||||
|> assign(property_type: property_type)
|
||||
|> assign(:page_title, page_title)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
defp return_to("show"), do: "show"
|
||||
defp return_to(_), do: "index"
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"property_type" => property_type_params}, socket) do
|
||||
{:noreply,
|
||||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, property_type_params))}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"property_type" => property_type_params}, socket) do
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: property_type_params) do
|
||||
{:ok, property_type} ->
|
||||
notify_parent({:saved, property_type})
|
||||
|
||||
action =
|
||||
case socket.assigns.form.source.type do
|
||||
:create -> gettext("create")
|
||||
:update -> gettext("update")
|
||||
other -> to_string(other)
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:info, gettext("Property type %{action} successfully", action: action))
|
||||
|> push_navigate(to: return_path(socket.assigns.return_to, property_type))
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, form} ->
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
end
|
||||
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
|
||||
defp assign_form(%{assigns: %{property_type: property_type}} = socket) do
|
||||
form =
|
||||
if property_type do
|
||||
AshPhoenix.Form.for_update(property_type, :update, as: "property_type")
|
||||
else
|
||||
AshPhoenix.Form.for_create(Mv.Membership.PropertyType, :create, as: "property_type")
|
||||
end
|
||||
|
||||
assign(socket, form: to_form(form))
|
||||
end
|
||||
|
||||
defp return_path("index", _property_type), do: ~p"/property_types"
|
||||
defp return_path("show", property_type), do: ~p"/property_types/#{property_type.id}"
|
||||
end
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
defmodule MvWeb.PropertyTypeLive.Index do
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
Listing Property types
|
||||
<:actions>
|
||||
<.button variant="primary" navigate={~p"/property_types/new"}>
|
||||
<.icon name="hero-plus" /> New Property type
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.table
|
||||
id="property_types"
|
||||
rows={@streams.property_types}
|
||||
row_click={fn {_id, property_type} -> JS.navigate(~p"/property_types/#{property_type}") end}
|
||||
>
|
||||
<:col :let={{_id, property_type}} label="Id">{property_type.id}</:col>
|
||||
|
||||
<:col :let={{_id, property_type}} label="Name">{property_type.name}</:col>
|
||||
|
||||
<:col :let={{_id, property_type}} label="Description">{property_type.description}</:col>
|
||||
|
||||
<:action :let={{_id, property_type}}>
|
||||
<div class="sr-only">
|
||||
<.link navigate={~p"/property_types/#{property_type}"}>Show</.link>
|
||||
</div>
|
||||
|
||||
<.link navigate={~p"/property_types/#{property_type}/edit"}>Edit</.link>
|
||||
</:action>
|
||||
|
||||
<:action :let={{id, property_type}}>
|
||||
<.link
|
||||
phx-click={JS.push("delete", value: %{id: property_type.id}) |> hide("##{id}")}
|
||||
data-confirm="Are you sure?"
|
||||
>
|
||||
Delete
|
||||
</.link>
|
||||
</:action>
|
||||
</.table>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Listing Property types")
|
||||
|> stream(:property_types, Ash.read!(Mv.Membership.PropertyType))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
property_type = Ash.get!(Mv.Membership.PropertyType, id)
|
||||
Ash.destroy!(property_type)
|
||||
|
||||
{:noreply, stream_delete(socket, :property_types, property_type)}
|
||||
end
|
||||
end
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
defmodule MvWeb.PropertyTypeLive.Show do
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
Property type {@property_type.id}
|
||||
<:subtitle>This is a property_type record from your database.</:subtitle>
|
||||
|
||||
<:actions>
|
||||
<.button navigate={~p"/property_types"}>
|
||||
<.icon name="hero-arrow-left" />
|
||||
</.button>
|
||||
<.button
|
||||
variant="primary"
|
||||
navigate={~p"/property_types/#{@property_type}/edit?return_to=show"}
|
||||
>
|
||||
<.icon name="hero-pencil-square" /> Edit Property type
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.list>
|
||||
<:item title="Id">{@property_type.id}</:item>
|
||||
|
||||
<:item title="Name">{@property_type.name}</:item>
|
||||
|
||||
<:item title="Description">{@property_type.description}</:item>
|
||||
</.list>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Show Property type")
|
||||
|> assign(:property_type, Ash.get!(Mv.Membership.PropertyType, id))}
|
||||
end
|
||||
end
|
||||
|
|
@ -1,4 +1,36 @@
|
|||
defmodule MvWeb.UserLive.Form do
|
||||
@moduledoc """
|
||||
LiveView form for creating and editing users.
|
||||
|
||||
## Features
|
||||
- Create new users with email
|
||||
- Edit existing user details
|
||||
- Optional password setting (checkbox to toggle)
|
||||
- Link/unlink member accounts
|
||||
- Email synchronization with linked members
|
||||
|
||||
## Form Fields
|
||||
**Required:**
|
||||
- email
|
||||
|
||||
**Optional:**
|
||||
- password (for password authentication strategy)
|
||||
- linked member (select from existing members)
|
||||
|
||||
## Password Management
|
||||
- New users: Can optionally set password with confirmation
|
||||
- Existing users: Can change password (no confirmation required, admin action)
|
||||
- Checkbox toggles password section visibility
|
||||
|
||||
## Member Linking
|
||||
Users can be linked to existing member accounts. When linked, emails are
|
||||
synchronized bidirectionally with User.email as the source of truth.
|
||||
|
||||
## Events
|
||||
- `validate` - Real-time form validation
|
||||
- `save` - Submit form (create or update user)
|
||||
- `toggle_password_section` - Show/hide password fields
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
|
|
@ -89,6 +121,130 @@ defmodule MvWeb.UserLive.Form do
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Member Linking Section -->
|
||||
<div class="mt-6">
|
||||
<h2 class="text-base font-semibold mb-3">{gettext("Linked Member")}</h2>
|
||||
|
||||
<%= if @user && @user.member && !@unlink_member do %>
|
||||
<!-- Show linked member with unlink button -->
|
||||
<div class="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-green-900">
|
||||
{@user.member.first_name} {@user.member.last_name}
|
||||
</p>
|
||||
<p class="text-sm text-green-700">{@user.member.email}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="unlink_member"
|
||||
class="btn btn-sm btn-error"
|
||||
>
|
||||
{gettext("Unlink Member")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= if @unlink_member do %>
|
||||
<!-- Show unlink pending message -->
|
||||
<div class="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p class="text-sm text-yellow-800">
|
||||
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
|
||||
"Member will be unlinked when you save. Cannot select new member until saved."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
<!-- Show member search/selection for unlinked users -->
|
||||
<div class="space-y-3">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="member-search-input"
|
||||
role="combobox"
|
||||
phx-hook="ComboBox"
|
||||
phx-focus="show_member_dropdown"
|
||||
phx-change="search_members"
|
||||
phx-debounce="300"
|
||||
phx-window-keydown="member_dropdown_keydown"
|
||||
value={@member_search_query}
|
||||
placeholder={gettext("Search for a member to link...")}
|
||||
class="w-full input"
|
||||
name="member_search"
|
||||
disabled={@unlink_member}
|
||||
aria-label={gettext("Search for member to link")}
|
||||
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="member-dropdown"
|
||||
aria-expanded={to_string(@show_member_dropdown)}
|
||||
aria-activedescendant={
|
||||
if @focused_member_index,
|
||||
do: "member-option-#{@focused_member_index}",
|
||||
else: nil
|
||||
}
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<%= if length(@available_members) > 0 do %>
|
||||
<div
|
||||
id="member-dropdown"
|
||||
role="listbox"
|
||||
aria-label={gettext("Available members")}
|
||||
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
|
||||
phx-click-away="hide_member_dropdown"
|
||||
>
|
||||
<%= for {member, index} <- Enum.with_index(@available_members) do %>
|
||||
<div
|
||||
id={"member-option-#{index}"}
|
||||
role="option"
|
||||
tabindex="0"
|
||||
aria-selected={to_string(@focused_member_index == index)}
|
||||
phx-click="select_member"
|
||||
phx-value-id={member.id}
|
||||
data-member-id={member.id}
|
||||
class={[
|
||||
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
||||
if(@focused_member_index == index,
|
||||
do: "bg-base-300",
|
||||
else: "hover:bg-base-200"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<p class="font-medium">{member.first_name} {member.last_name}</p>
|
||||
<p class="text-sm text-base-content/70">{member.email}</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
|
||||
<div class="p-3 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<p class="text-sm text-yellow-800">
|
||||
<strong>{gettext("Note")}:</strong> {gettext(
|
||||
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @selected_member_id && @selected_member_name do %>
|
||||
<div
|
||||
id="member-selected"
|
||||
class="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg"
|
||||
>
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
|
||||
</p>
|
||||
<p class="text-xs text-blue-600 mt-1">
|
||||
{gettext("Save to confirm linking.")}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
{gettext("Save User")}
|
||||
</.button>
|
||||
|
|
@ -103,7 +259,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
user =
|
||||
case params["id"] do
|
||||
nil -> nil
|
||||
id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts)
|
||||
id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member])
|
||||
end
|
||||
|
||||
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
|
||||
|
|
@ -115,9 +271,18 @@ defmodule MvWeb.UserLive.Form do
|
|||
|> assign(user: user)
|
||||
|> assign(:page_title, page_title)
|
||||
|> assign(:show_password_fields, false)
|
||||
|> assign(:member_search_query, "")
|
||||
|> assign(:available_members, [])
|
||||
|> assign(:show_member_dropdown, false)
|
||||
|> assign(:selected_member_id, nil)
|
||||
|> assign(:selected_member_name, nil)
|
||||
|> assign(:unlink_member, false)
|
||||
|> assign(:focused_member_index, nil)
|
||||
|> load_initial_members()
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
@spec return_to(String.t() | nil) :: String.t()
|
||||
defp return_to("show"), do: "show"
|
||||
defp return_to(_), do: "index"
|
||||
|
||||
|
|
@ -134,28 +299,201 @@ defmodule MvWeb.UserLive.Form do
|
|||
end
|
||||
|
||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||
{:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, user_params))}
|
||||
validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params)
|
||||
|
||||
# Reload members if email changed (for email-match priority)
|
||||
socket =
|
||||
if Map.has_key?(user_params, "email") do
|
||||
user_email = user_params["email"]
|
||||
members = load_members_for_linking(user_email, socket.assigns.member_search_query)
|
||||
|
||||
assign(socket, form: validated_form, available_members: members)
|
||||
else
|
||||
assign(socket, form: validated_form)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"user" => user_params}, socket) do
|
||||
# First save the user without member changes
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: user_params) do
|
||||
{:ok, user} ->
|
||||
notify_parent({:saved, user})
|
||||
# Then handle member linking/unlinking as a separate step
|
||||
result =
|
||||
cond do
|
||||
# Selected member ID takes precedence (new link)
|
||||
socket.assigns.selected_member_id ->
|
||||
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}})
|
||||
|
||||
# Unlink flag is set
|
||||
socket.assigns[:unlink_member] ->
|
||||
Mv.Accounts.update_user(user, %{member: nil})
|
||||
|
||||
# No changes to member relationship
|
||||
true ->
|
||||
{:ok, user}
|
||||
end
|
||||
|
||||
case result do
|
||||
{:ok, updated_user} ->
|
||||
notify_parent({:saved, updated_user})
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully")
|
||||
|> push_navigate(to: return_path(socket.assigns.return_to, user))
|
||||
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, error} ->
|
||||
# Show user-friendly error from member linking/unlinking
|
||||
error_message = extract_error_message(error)
|
||||
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Failed to link member: %{error}", error: error_message)
|
||||
)}
|
||||
end
|
||||
|
||||
{:error, form} ->
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("show_member_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, show_member_dropdown: true)}
|
||||
end
|
||||
|
||||
def handle_event("hide_member_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
|
||||
end
|
||||
|
||||
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
max_index = length(socket.assigns.available_members) - 1
|
||||
current = socket.assigns.focused_member_index
|
||||
|
||||
new_index =
|
||||
case current do
|
||||
nil -> 0
|
||||
index when index < max_index -> index + 1
|
||||
_ -> current
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, focused_member_index: new_index)}
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
current = socket.assigns.focused_member_index
|
||||
|
||||
new_index =
|
||||
case current do
|
||||
nil -> 0
|
||||
0 -> 0
|
||||
index -> index - 1
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, focused_member_index: new_index)}
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
select_focused_member(socket)
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do
|
||||
return_if_dropdown_closed(socket, fn ->
|
||||
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
|
||||
end)
|
||||
end
|
||||
|
||||
def handle_event("member_dropdown_keydown", _params, socket) do
|
||||
# Ignore other keys
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("search_members", %{"member_search" => query}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:member_search_query, query)
|
||||
|> load_available_members(query)
|
||||
|> assign(:show_member_dropdown, true)
|
||||
|> assign(:focused_member_index, nil)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("select_member", %{"id" => member_id}, socket) do
|
||||
# Find the selected member to get their name
|
||||
selected_member = Enum.find(socket.assigns.available_members, &(&1.id == member_id))
|
||||
|
||||
member_name =
|
||||
if selected_member,
|
||||
do: "#{selected_member.first_name} #{selected_member.last_name}",
|
||||
else: ""
|
||||
|
||||
# Store the selected member ID and name in socket state and clear unlink flag
|
||||
socket =
|
||||
socket
|
||||
|> assign(:selected_member_id, member_id)
|
||||
|> assign(:selected_member_name, member_name)
|
||||
|> assign(:unlink_member, false)
|
||||
|> assign(:show_member_dropdown, false)
|
||||
|> assign(:member_search_query, member_name)
|
||||
|> push_event("set-input-value", %{id: "member-search-input", value: member_name})
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("unlink_member", _params, socket) do
|
||||
# Set flag to unlink member on save
|
||||
# Clear all member selection state and keep dropdown hidden
|
||||
socket =
|
||||
socket
|
||||
|> assign(:unlink_member, true)
|
||||
|> assign(:selected_member_id, nil)
|
||||
|> assign(:selected_member_name, nil)
|
||||
|> assign(:member_search_query, "")
|
||||
|> assign(:show_member_dropdown, false)
|
||||
|> load_initial_members()
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@spec notify_parent(any()) :: any()
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
|
||||
# Helper to ignore keyboard events when dropdown is closed
|
||||
@spec return_if_dropdown_closed(Phoenix.LiveView.Socket.t(), function()) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
defp return_if_dropdown_closed(socket, func) do
|
||||
if socket.assigns.show_member_dropdown do
|
||||
func.()
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
# Select the currently focused member from the dropdown
|
||||
@spec select_focused_member(Phoenix.LiveView.Socket.t()) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
defp select_focused_member(socket) do
|
||||
with index when not is_nil(index) <- socket.assigns.focused_member_index,
|
||||
member when not is_nil(member) <- Enum.at(socket.assigns.available_members, index) do
|
||||
handle_event("select_member", %{"id" => member.id}, socket)
|
||||
else
|
||||
_ -> {:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
||||
defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
|
||||
form =
|
||||
if user do
|
||||
|
|
@ -175,6 +513,71 @@ defmodule MvWeb.UserLive.Form do
|
|||
assign(socket, form: to_form(form))
|
||||
end
|
||||
|
||||
@spec return_path(String.t(), Mv.Accounts.User.t() | nil) :: String.t()
|
||||
defp return_path("index", _user), do: ~p"/users"
|
||||
defp return_path("show", user), do: ~p"/users/#{user.id}"
|
||||
|
||||
@spec load_initial_members(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
||||
defp load_initial_members(socket) do
|
||||
user = socket.assigns.user
|
||||
user_email = if user, do: user.email, else: nil
|
||||
|
||||
members = load_members_for_linking(user_email, "")
|
||||
|
||||
# Dropdown should ALWAYS be hidden initially
|
||||
# It will only show when user focuses the input field (show_member_dropdown event)
|
||||
socket
|
||||
|> assign(available_members: members)
|
||||
|> assign(show_member_dropdown: false)
|
||||
end
|
||||
|
||||
@spec load_available_members(Phoenix.LiveView.Socket.t(), String.t()) ::
|
||||
Phoenix.LiveView.Socket.t()
|
||||
defp load_available_members(socket, query) do
|
||||
user = socket.assigns.user
|
||||
user_email = if user, do: user.email, else: nil
|
||||
|
||||
members = load_members_for_linking(user_email, query)
|
||||
assign(socket, available_members: members)
|
||||
end
|
||||
|
||||
@spec load_members_for_linking(String.t() | nil, String.t() | nil) :: [Mv.Membership.Member.t()]
|
||||
defp load_members_for_linking(user_email, search_query) do
|
||||
user_email_str = if user_email, do: to_string(user_email), else: nil
|
||||
search_query_str = if search_query && search_query != "", do: search_query, else: nil
|
||||
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.for_read(:available_for_linking, %{
|
||||
user_email: user_email_str,
|
||||
search_query: search_query_str
|
||||
})
|
||||
|
||||
case Ash.read(query, domain: Mv.Membership) do
|
||||
{:ok, members} ->
|
||||
# Apply email match filter if user_email is provided
|
||||
if user_email_str do
|
||||
Mv.Membership.Member.filter_by_email_match(members, user_email_str)
|
||||
else
|
||||
members
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
# Extract user-friendly error message from Ash.Error
|
||||
@spec extract_error_message(any()) :: String.t()
|
||||
defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
|
||||
# Take first error and extract message
|
||||
case List.first(errors) do
|
||||
%{message: message} when is_binary(message) -> message
|
||||
%{field: field, message: message} -> "#{field}: #{message}"
|
||||
_ -> "Unknown error"
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_error_message(error) when is_binary(error), do: error
|
||||
defp extract_error_message(_), do: "Unknown error"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,10 +1,31 @@
|
|||
defmodule MvWeb.UserLive.Index do
|
||||
@moduledoc """
|
||||
LiveView for displaying and managing the user list.
|
||||
|
||||
## Features
|
||||
- List all users with email and linked member
|
||||
- Sort users by email (default)
|
||||
- Delete users
|
||||
- Navigate to user details and edit forms
|
||||
- Bulk selection for future batch operations
|
||||
|
||||
## Relationships
|
||||
Displays linked member information when a user is connected to a member account.
|
||||
|
||||
## Events
|
||||
- `delete` - Remove a user from the database
|
||||
- `select_user` - Toggle individual user selection
|
||||
- `select_all` - Toggle selection of all visible users
|
||||
|
||||
## Security
|
||||
User deletion requires admin permissions (enforced by Ash policies).
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
import MvWeb.TableComponents
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts)
|
||||
users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts, load: [:member])
|
||||
sorted = Enum.sort_by(users, & &1.email)
|
||||
|
||||
{:ok,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,13 @@
|
|||
{user.email}
|
||||
</:col>
|
||||
<:col :let={user} label={gettext("OIDC ID")}>{user.oidc_id}</:col>
|
||||
<:col :let={user} label={gettext("Linked Member")}>
|
||||
<%= if user.member do %>
|
||||
{user.member.first_name} {user.member.last_name}
|
||||
<% else %>
|
||||
<span class="text-base-content/50">{gettext("No member linked")}</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
|
||||
<:action :let={user}>
|
||||
<div class="sr-only">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,29 @@
|
|||
defmodule MvWeb.UserLive.Show do
|
||||
@moduledoc """
|
||||
LiveView for displaying a single user's details.
|
||||
|
||||
## Features
|
||||
- Display user information (email, OIDC ID)
|
||||
- Show authentication methods (password, OIDC)
|
||||
- Display linked member account (if exists)
|
||||
- Navigate to edit form
|
||||
- Return to user list
|
||||
|
||||
## Displayed Information
|
||||
- Email address
|
||||
- OIDC ID (if authenticated via OIDC)
|
||||
- Password authentication status
|
||||
- Linked member (name and email)
|
||||
|
||||
## Authentication Status
|
||||
Shows which authentication methods are enabled for the user:
|
||||
- Password authentication (has hashed_password)
|
||||
- OIDC authentication (has oidc_id)
|
||||
|
||||
## Navigation
|
||||
- Back to user list
|
||||
- Edit user (with return_to parameter for back navigation)
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
|
|
@ -1,4 +1,16 @@
|
|||
defmodule MvWeb.LiveHelpers do
|
||||
@moduledoc """
|
||||
Shared LiveView lifecycle hooks and helper functions.
|
||||
|
||||
## on_mount Hooks
|
||||
- `:default` - Sets the user's locale from session (defaults to "de")
|
||||
|
||||
## Usage
|
||||
Add to LiveView modules via:
|
||||
```elixir
|
||||
on_mount {MvWeb.LiveHelpers, :default}
|
||||
```
|
||||
"""
|
||||
def on_mount(:default, _params, session, socket) do
|
||||
locale = session["locale"] || "de"
|
||||
Gettext.put_locale(locale)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@ defmodule MvWeb.LocaleController do
|
|||
def set_locale(conn, %{"locale" => locale}) do
|
||||
conn
|
||||
|> put_session(:locale, locale)
|
||||
# Store locale in a cookie that persists beyond the session
|
||||
|> put_resp_cookie("locale", locale,
|
||||
max_age: 365 * 24 * 60 * 60,
|
||||
same_site: "Lax",
|
||||
http_only: true,
|
||||
secure: Application.get_env(:mv, :use_secure_cookies, false)
|
||||
)
|
||||
|> redirect(to: get_referer(conn) || "/")
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -55,17 +55,17 @@ defmodule MvWeb.Router do
|
|||
live "/members/:id", MemberLive.Show, :show
|
||||
live "/members/:id/show/edit", MemberLive.Show, :edit
|
||||
|
||||
live "/property_types", PropertyTypeLive.Index, :index
|
||||
live "/property_types/new", PropertyTypeLive.Form, :new
|
||||
live "/property_types/:id/edit", PropertyTypeLive.Form, :edit
|
||||
live "/property_types/:id", PropertyTypeLive.Show, :show
|
||||
live "/property_types/:id/show/edit", PropertyTypeLive.Show, :edit
|
||||
live "/custom_fields", CustomFieldLive.Index, :index
|
||||
live "/custom_fields/new", CustomFieldLive.Form, :new
|
||||
live "/custom_fields/:id/edit", CustomFieldLive.Form, :edit
|
||||
live "/custom_fields/:id", CustomFieldLive.Show, :show
|
||||
live "/custom_fields/:id/show/edit", CustomFieldLive.Show, :edit
|
||||
|
||||
live "/properties", PropertyLive.Index, :index
|
||||
live "/properties/new", PropertyLive.Form, :new
|
||||
live "/properties/:id/edit", PropertyLive.Form, :edit
|
||||
live "/properties/:id", PropertyLive.Show, :show
|
||||
live "/properties/:id/show/edit", PropertyLive.Show, :edit
|
||||
live "/custom_field_values", CustomFieldValueLive.Index, :index
|
||||
live "/custom_field_values/new", CustomFieldValueLive.Form, :new
|
||||
live "/custom_field_values/:id/edit", CustomFieldValueLive.Form, :edit
|
||||
live "/custom_field_values/:id", CustomFieldValueLive.Show, :show
|
||||
live "/custom_field_values/:id/show/edit", CustomFieldValueLive.Show, :edit
|
||||
|
||||
live "/users", UserLive.Index, :index
|
||||
live "/users/new", UserLive.Form, :new
|
||||
|
|
@ -73,9 +73,14 @@ defmodule MvWeb.Router do
|
|||
live "/users/:id", UserLive.Show, :show
|
||||
live "/users/:id/show/edit", UserLive.Show, :edit
|
||||
|
||||
live "/settings", GlobalSettingsLive
|
||||
|
||||
post "/set_locale", LocaleController, :set_locale
|
||||
end
|
||||
|
||||
# OIDC account linking - user needs to verify password (MUST be before auth_routes!)
|
||||
live "/auth/link-oidc-account", LinkOidcAccountLive
|
||||
|
||||
# ASHAUTHENTICATION GENERATED AUTH ROUTES
|
||||
auth_routes AuthController, Mv.Accounts.User, path: "/auth"
|
||||
sign_out_route AuthController
|
||||
|
|
@ -141,6 +146,7 @@ defmodule MvWeb.Router do
|
|||
defp set_locale(conn, _opts) do
|
||||
locale =
|
||||
get_session(conn, :locale) ||
|
||||
get_locale_from_cookie(conn) ||
|
||||
extract_locale_from_headers(conn.req_headers)
|
||||
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
|
@ -150,6 +156,13 @@ defmodule MvWeb.Router do
|
|||
|> assign(:locale, locale)
|
||||
end
|
||||
|
||||
defp get_locale_from_cookie(conn) do
|
||||
case conn.req_cookies do
|
||||
%{"locale" => locale} when locale in ["en", "de"] -> locale
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Get locale from user
|
||||
defp extract_locale_from_headers(headers) do
|
||||
headers
|
||||
|
|
|
|||
5
mix.exs
5
mix.exs
|
|
@ -22,7 +22,7 @@ defmodule Mv.MixProject do
|
|||
def application do
|
||||
[
|
||||
mod: {Mv.Application, []},
|
||||
extra_applications: [:logger, :runtime_tools]
|
||||
extra_applications: [:logger, :runtime_tools, :gettext]
|
||||
]
|
||||
end
|
||||
|
||||
|
|
@ -75,7 +75,8 @@ defmodule Mv.MixProject do
|
|||
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
|
||||
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
{:ecto_commons, "~> 0.3"}
|
||||
{:ecto_commons, "~> 0.3"},
|
||||
{:slugify, "~> 1.3"}
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
|||
4
mix.lock
4
mix.lock
|
|
@ -16,7 +16,7 @@
|
|||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||
"credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"},
|
||||
"crux": {:hex, :crux, "0.1.1", "94f2f97d2a6079ae3c57f356412bc3b307f9579a80e43f526447b1d508dd4a72", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "e59d498f038193cbe31e448f9199f5b4c53a4c67cece9922bb839595189dd2b6"},
|
||||
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
|
||||
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
|
||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||
"ecto": {:hex, :ecto, "3.13.3", "6a983f0917f8bdc7a89e96f2bf013f220503a0da5d8623224ba987515b3f0d80", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1927db768f53a88843ff25b6ba7946599a8ca8a055f69ad8058a1432a399af94"},
|
||||
|
|
@ -80,7 +80,7 @@
|
|||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
||||
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
|
||||
"tidewave": {:hex, :tidewave, "0.5.0", "8f278d7eb2d0af36ae6d4f73a5872bd066815bd57b57401125187ba901f095a4", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "9a1eb5d2f12ff4912328dfbfe652c27fded462c6ed6fd11814ee28d3e9d016b4"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
|
|
|
|||
58
notes.md
Normal file
58
notes.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# User-Member Association - Test Status
|
||||
|
||||
## Test Files Created/Modified
|
||||
|
||||
### 1. test/membership/member_available_for_linking_test.exs (NEU)
|
||||
**Status**: Alle Tests sollten FEHLSCHLAGEN ❌
|
||||
**Grund**: Die `:available_for_linking` Action existiert noch nicht
|
||||
|
||||
Tests:
|
||||
- ✗ returns only unlinked members and limits to 10
|
||||
- ✗ limits results to 10 members even when more exist
|
||||
- ✗ email match: returns only member with matching email when exists
|
||||
- ✗ email match: returns all unlinked members when no email match
|
||||
- ✗ search query: filters by first_name, last_name, and email
|
||||
- ✗ email match takes precedence over search query
|
||||
|
||||
### 2. test/accounts/user_member_linking_test.exs (NEU)
|
||||
**Status**: Tests sollten teilweise ERFOLGREICH sein ✅ / teilweise FEHLSCHLAGEN ❌
|
||||
|
||||
Tests:
|
||||
- ✓ link user to member with different email syncs member email (sollte BESTEHEN - Email-Sync ist implementiert)
|
||||
- ✓ unlink member from user sets member to nil (sollte BESTEHEN - Unlink ist implementiert)
|
||||
- ✓ cannot link member already linked to another user (sollte BESTEHEN - Validierung existiert)
|
||||
- ✓ cannot change member link directly, must unlink first (sollte BESTEHEN - Validierung existiert)
|
||||
|
||||
### 3. test/mv_web/user_live/form_test.exs (ERWEITERT)
|
||||
**Status**: Alle neuen Tests sollten FEHLSCHLAGEN ❌
|
||||
**Grund**: Member-Linking UI ist noch nicht implementiert
|
||||
|
||||
Neue Tests:
|
||||
- ✗ shows linked member with unlink button when user has member
|
||||
- ✗ shows member search field when user has no member
|
||||
- ✗ selecting member and saving links member to user
|
||||
- ✗ unlinking member and saving removes member from user
|
||||
|
||||
### 4. test/mv_web/user_live/index_test.exs (ERWEITERT)
|
||||
**Status**: Neuer Test sollte FEHLSCHLAGEN ❌
|
||||
**Grund**: Member-Spalte wird noch nicht in der Index-View angezeigt
|
||||
|
||||
Neuer Test:
|
||||
- ✗ displays linked member name in user list
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
**Tests gesamt**: 13
|
||||
**Sollten BESTEHEN**: 4 (Backend-Validierungen bereits vorhanden)
|
||||
**Sollten FEHLSCHLAGEN**: 9 (Features noch nicht implementiert)
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. Implementiere `:available_for_linking` Action in `lib/membership/member.ex`
|
||||
2. Erstelle `MemberAutocompleteComponent` in `lib/mv_web/live/components/member_autocomplete_component.ex`
|
||||
3. Integriere Member-Linking UI in `lib/mv_web/live/user_live/form.ex`
|
||||
4. Füge Member-Spalte zu `lib/mv_web/live/user_live/index.ex` hinzu
|
||||
5. Füge Gettext-Übersetzungen hinzu
|
||||
|
||||
Nach jeder Implementierung: Tests erneut ausführen und prüfen, ob sie grün werden.
|
||||
|
||||
|
|
@ -36,6 +36,8 @@ msgstr ""
|
|||
msgid "Need an account?"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:268
|
||||
#, elixir-autogen
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -62,3 +64,79 @@ msgstr ""
|
|||
|
||||
msgid "Your password has successfully been reset"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:254
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:289
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:163
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Incorrect password. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:37
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid session. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:281
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Link Account"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:252
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Link OIDC Account"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:280
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Linking..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:40
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Session expired. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:209
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:76
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Account activated! Redirecting to complete sign-in..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:119
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:123
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to link account. Please try again or contact support."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:108
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:98
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This OIDC account is already linked to another user. Please contact support."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:235
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Language selection"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:242
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select language"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ msgstr "Falls diese*r Benutzer*in bekannt ist, wird jetzt eine Email mit einer A
|
|||
msgid "Need an account?"
|
||||
msgstr "Konto anlegen?"
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:268
|
||||
#, elixir-autogen
|
||||
msgid "Password"
|
||||
msgstr "Passwort"
|
||||
|
||||
|
|
@ -61,3 +63,79 @@ msgstr "Anmelden..."
|
|||
|
||||
msgid "Your password has successfully been reset"
|
||||
msgstr "Das Passwort wurde erfolgreich zurückgesetzt"
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:254
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
|
||||
msgstr "Ein Konto mit der E-Mail %{email} existiert bereits. Bitte geben Sie Ihr Passwort ein, um Ihr OIDC-Konto zu verknüpfen."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:289
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
msgstr "Abbrechen"
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:163
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Incorrect password. Please try again."
|
||||
msgstr "Falsches Passwort. Bitte versuchen Sie es erneut."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:37
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid session. Please try again."
|
||||
msgstr "Ungültige Sitzung. Bitte versuchen Sie es erneut."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:281
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Link Account"
|
||||
msgstr "Konto verknüpfen"
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:252
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Link OIDC Account"
|
||||
msgstr "OIDC-Konto verknüpfen"
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:280
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Linking..."
|
||||
msgstr "Verknüpfen..."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:40
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Session expired. Please try again."
|
||||
msgstr "Sitzung abgelaufen. Bitte versuchen Sie es erneut."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:209
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
||||
msgstr "Ihr OIDC-Konto wurde erfolgreich verknüpft! Sie werden zur Anmeldung weitergeleitet..."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:76
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Account activated! Redirecting to complete sign-in..."
|
||||
msgstr "Konto aktiviert! Sie werden zur Anmeldung weitergeleitet..."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:119
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:123
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to link account. Please try again or contact support."
|
||||
msgstr "Verknüpfung des Kontos fehlgeschlagen. Bitte versuchen Sie es erneut oder kontaktieren Sie den Support."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:108
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
|
||||
msgstr "Die E-Mail-Adresse aus Ihrem OIDC-Provider ist bereits für ein anderes Konto registriert. Bitte ändern Sie Ihre E-Mail-Adresse im Identity-Provider oder kontaktieren Sie den Support."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:98
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This OIDC account is already linked to another user. Please contact support."
|
||||
msgstr "Dieses OIDC-Konto ist bereits mit einem anderen Benutzer verknüpft. Bitte kontaktieren Sie den Support."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:235
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Language selection"
|
||||
msgstr "Sprachauswahl"
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:242
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select language"
|
||||
msgstr "Sprache auswählen"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -155,3 +155,7 @@ msgstr "muss mindestens 8 Zeichen lang sein"
|
|||
|
||||
msgid "is required"
|
||||
msgstr "ist erforderlich"
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
msgid "Failed to link member: %{error}"
|
||||
msgstr "Fehler beim Verknüpfen des Mitglieds: %{error}"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -32,6 +32,8 @@ msgstr ""
|
|||
msgid "Need an account?"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:268
|
||||
#, elixir-autogen
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -58,3 +60,79 @@ msgstr ""
|
|||
|
||||
msgid "Your password has successfully been reset"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:254
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:289
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:163
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Incorrect password. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:37
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid session. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:281
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Link Account"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:252
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Link OIDC Account"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:280
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Linking..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:40
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Session expired. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:209
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:76
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Account activated! Redirecting to complete sign-in..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:119
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:123
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to link account. Please try again or contact support."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:108
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:98
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This OIDC account is already linked to another user. Please contact support."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:235
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Language selection"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:242
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select language"
|
||||
msgstr ""
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -155,3 +155,7 @@ msgstr ""
|
|||
|
||||
msgid "is required"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
msgid "Failed to link member: %{error}"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -152,3 +152,7 @@ msgstr ""
|
|||
|
||||
msgid "is required"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/form.ex
|
||||
msgid "Failed to link member: %{error}"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
defmodule Mv.Repo.Migrations.AddTrigramToMembers do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
# activate trigram-extension
|
||||
execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
|
||||
|
||||
# -------------------------------------------------
|
||||
# Trigram‑Indizes (GIN) for fields we want to search in
|
||||
# -------------------------------------------------
|
||||
#
|
||||
# `gin_trgm_ops` ist the operator-class-name
|
||||
#
|
||||
|
||||
execute("""
|
||||
CREATE INDEX members_first_name_trgm_idx
|
||||
ON members
|
||||
USING GIN (first_name gin_trgm_ops);
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE INDEX members_last_name_trgm_idx
|
||||
ON members
|
||||
USING GIN (last_name gin_trgm_ops);
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE INDEX members_email_trgm_idx
|
||||
ON members
|
||||
USING GIN (email gin_trgm_ops);
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE INDEX members_city_trgm_idx
|
||||
ON members
|
||||
USING GIN (city gin_trgm_ops);
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE INDEX members_street_trgm_idx
|
||||
ON members
|
||||
USING GIN (street gin_trgm_ops);
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE INDEX members_notes_trgm_idx
|
||||
ON members
|
||||
USING GIN (notes gin_trgm_ops);
|
||||
""")
|
||||
end
|
||||
|
||||
def down do
|
||||
execute("DROP INDEX IF EXISTS members_first_name_trgm_idx;")
|
||||
execute("DROP INDEX IF EXISTS members_last_name_trgm_idx;")
|
||||
execute("DROP INDEX IF EXISTS members_email_trgm_idx;")
|
||||
execute("DROP INDEX IF EXISTS members_city_trgm_idx;")
|
||||
execute("DROP INDEX IF EXISTS members_street_trgm_idx;")
|
||||
execute("DROP INDEX IF EXISTS members_notes_trgm_idx;")
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
defmodule Mv.Repo.Migrations.RenamePropertiesToCustomFieldsExtensions1 do
|
||||
@moduledoc """
|
||||
Installs any extensions that are mentioned in the repo's `installed_extensions/0` callback
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
execute("CREATE EXTENSION IF NOT EXISTS \"pg_trgm\"")
|
||||
end
|
||||
|
||||
def down do
|
||||
# Uncomment this if you actually want to uninstall the extensions
|
||||
# when this migration is rolled back:
|
||||
# execute("DROP EXTENSION IF EXISTS \"pg_trgm\"")
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
defmodule Mv.Repo.Migrations.RenamePropertiesToCustomFields do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
# Rename tables
|
||||
rename table("property_types"), to: table("custom_fields")
|
||||
rename table("properties"), to: table("custom_field_values")
|
||||
|
||||
# Rename the foreign key column
|
||||
rename table("custom_field_values"), :property_type_id, to: :custom_field_id
|
||||
|
||||
# Drop old foreign key constraints
|
||||
drop constraint(:custom_field_values, "properties_member_id_fkey")
|
||||
drop constraint(:custom_field_values, "properties_property_type_id_fkey")
|
||||
|
||||
# Add new foreign key constraints with correct names and on_delete behavior
|
||||
alter table(:custom_field_values) do
|
||||
modify :member_id,
|
||||
references(:members,
|
||||
column: :id,
|
||||
name: "custom_field_values_member_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public",
|
||||
on_delete: :delete_all
|
||||
)
|
||||
|
||||
modify :custom_field_id,
|
||||
references(:custom_fields,
|
||||
column: :id,
|
||||
name: "custom_field_values_custom_field_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public"
|
||||
)
|
||||
end
|
||||
|
||||
# Rename indexes
|
||||
execute "ALTER INDEX IF EXISTS property_types_unique_name_index RENAME TO custom_fields_unique_name_index"
|
||||
|
||||
execute "ALTER INDEX IF EXISTS properties_unique_property_per_member_index RENAME TO custom_field_values_unique_custom_field_per_member_index"
|
||||
end
|
||||
|
||||
def down do
|
||||
# Rename indexes back
|
||||
execute "ALTER INDEX IF EXISTS custom_fields_unique_name_index RENAME TO property_types_unique_name_index"
|
||||
|
||||
execute "ALTER INDEX IF EXISTS custom_field_values_unique_custom_field_per_member_index RENAME TO properties_unique_property_per_member_index"
|
||||
|
||||
# Drop new foreign key constraints
|
||||
drop constraint(:custom_field_values, "custom_field_values_member_id_fkey")
|
||||
drop constraint(:custom_field_values, "custom_field_values_custom_field_id_fkey")
|
||||
|
||||
# Add back old foreign key constraints
|
||||
alter table(:custom_field_values) do
|
||||
modify :member_id,
|
||||
references(:members,
|
||||
column: :id,
|
||||
name: "properties_member_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public"
|
||||
)
|
||||
|
||||
modify :custom_field_id,
|
||||
references(:custom_fields,
|
||||
column: :id,
|
||||
name: "properties_property_type_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public"
|
||||
)
|
||||
end
|
||||
|
||||
# Rename the foreign key column back
|
||||
rename table("custom_field_values"), :custom_field_id, to: :property_type_id
|
||||
|
||||
# Rename tables back
|
||||
rename table("custom_fields"), to: table("property_types")
|
||||
rename table("custom_field_values"), to: table("properties")
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
defmodule Mv.Repo.Migrations.AddSlugToCustomFields do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
# Step 1: Add slug column as nullable first
|
||||
alter table(:custom_fields) do
|
||||
add :slug, :text, null: true
|
||||
end
|
||||
|
||||
# Step 2: Generate slugs for existing custom fields
|
||||
execute("""
|
||||
UPDATE custom_fields
|
||||
SET slug = lower(
|
||||
regexp_replace(
|
||||
regexp_replace(
|
||||
regexp_replace(name, '[^a-zA-Z0-9\\s-]', '', 'g'),
|
||||
'\\s+', '-', 'g'
|
||||
),
|
||||
'-+', '-', 'g'
|
||||
)
|
||||
)
|
||||
WHERE slug IS NULL
|
||||
""")
|
||||
|
||||
# Step 3: Make slug NOT NULL
|
||||
alter table(:custom_fields) do
|
||||
modify :slug, :text, null: false
|
||||
end
|
||||
|
||||
# Step 4: Create unique index
|
||||
create unique_index(:custom_fields, [:slug], name: "custom_fields_unique_slug_index")
|
||||
end
|
||||
|
||||
def down do
|
||||
drop_if_exists unique_index(:custom_fields, [:slug], name: "custom_fields_unique_slug_index")
|
||||
|
||||
alter table(:custom_fields) do
|
||||
remove :slug
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
defmodule Mv.Repo.Migrations.ChangeCustomFieldDeleteCascade do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
drop constraint(:custom_field_values, "custom_field_values_custom_field_id_fkey")
|
||||
|
||||
alter table(:custom_field_values) do
|
||||
modify :custom_field_id,
|
||||
references(:custom_fields,
|
||||
column: :id,
|
||||
name: "custom_field_values_custom_field_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public",
|
||||
on_delete: :delete_all
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
drop constraint(:custom_field_values, "custom_field_values_custom_field_id_fkey")
|
||||
|
||||
alter table(:custom_field_values) do
|
||||
modify :custom_field_id,
|
||||
references(:custom_fields,
|
||||
column: :id,
|
||||
name: "custom_field_values_custom_field_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
defmodule Mv.Repo.Migrations.AddShowInOverviewToCustomFields do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:custom_fields) do
|
||||
add :show_in_overview, :boolean, null: false, default: true
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:custom_fields) do
|
||||
remove :show_in_overview
|
||||
end
|
||||
end
|
||||
end
|
||||
31
priv/repo/migrations/20251127134451_add_settings_table.exs
Normal file
31
priv/repo/migrations/20251127134451_add_settings_table.exs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
defmodule Mv.Repo.Migrations.AddSettingsTable do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
create table(:settings, primary_key: false) do
|
||||
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
|
||||
add :club_name, :text, null: false
|
||||
|
||||
add :inserted_at, :utc_datetime_usec,
|
||||
null: false,
|
||||
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||
|
||||
add :updated_at, :utc_datetime_usec,
|
||||
null: false,
|
||||
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||
end
|
||||
|
||||
# Note: Singleton pattern is enforced at application level via get_settings/0
|
||||
# which creates the record if it doesn't exist and only allows updates
|
||||
end
|
||||
|
||||
def down do
|
||||
drop table(:settings)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
defmodule Mv.Repo.Migrations.AddMemberFieldVisibilityToSettings do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:settings) do
|
||||
add :member_field_visibility, :map
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:settings) do
|
||||
remove :member_field_visibility
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
defmodule Mv.Repo.Migrations.RemoveBirthDateFromMembers do
|
||||
@moduledoc """
|
||||
Removes the birth_date column from the members table.
|
||||
|
||||
The birth_date field has been removed from the application because most users
|
||||
don't record birthday data. Users who need this can use a custom field instead.
|
||||
|
||||
This migration also updates the search_vector trigger to remove birth_date.
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
# Update the trigger function to remove birth_date from search_vector
|
||||
execute("""
|
||||
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C');
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
""")
|
||||
|
||||
# Remove the birth_date column
|
||||
alter table(:members) do
|
||||
remove :birth_date
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
# Add the birth_date column back
|
||||
alter table(:members) do
|
||||
add :birth_date, :date
|
||||
end
|
||||
|
||||
# Restore the trigger function with birth_date
|
||||
execute("""
|
||||
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.birth_date::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C');
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
""")
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue