diff --git a/assets/js/app.js b/assets/js/app.js index d5e278a..9b95296 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -23,11 +23,21 @@ import {LiveSocket} from "phoenix_live_view" import topbar from "../vendor/topbar" let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") + let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: {_csrf_token: csrfToken} }) +// 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 topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml index 431e064..33c0647 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -6,7 +6,7 @@ // - https://dbdocs.io // - VS Code Extensions: "DBML Language" or "dbdiagram.io" // -// Version: 1.1 +// Version: 1.2 // Last Updated: 2025-11-13 Project mila_membership_management { @@ -236,6 +236,7 @@ Table custom_field_values { 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'] @@ -243,6 +244,7 @@ Table custom_fields { indexes { name [unique, name: 'custom_fields_unique_name_index'] + slug [unique, name: 'custom_fields_unique_slug_index'] } Note: ''' @@ -252,21 +254,32 @@ Table custom_fields { **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) - - Emergency Contact (string, mutable, optional) - - Certified Trainer (boolean, mutable, optional) - - Certification Date (date, immutable, optional) + - 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" ''' } diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index f7447f2..1b86106 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -1321,6 +1321,135 @@ end --- +## Session: User-Member Linking UI Enhancement (2025-01-13) + +### Feature Summary +Implemented user-member linking functionality in User Edit/Create views with fuzzy search autocomplete, email conflict handling, and accessibility support. + +**Key Features:** +- Autocomplete dropdown with PostgreSQL Trigram fuzzy search +- Link/unlink members to user accounts +- Email synchronization between linked entities +- WCAG 2.1 AA compliant (ARIA labels) +- Bilingual UI (English/German) + +### Technical Decisions + +**1. Search Priority Logic** +Search query takes precedence over email filtering to provide better UX: +- User types → fuzzy search across all unlinked members +- Email matching only used for post-filtering when no search query present + +**2. JavaScript Hook for Input Value** +Used minimal JavaScript (~6 lines) for reliable input field updates: +```javascript +// assets/js/app.js +window.addEventListener("phx:set-input-value", (e) => { + document.getElementById(e.detail.id).value = e.detail.value +}) +``` +**Rationale:** LiveView DOM patching has race conditions with rapid state changes in autocomplete components. Direct DOM manipulation via `push_event` is the idiomatic LiveView solution for this edge case. + +**3. Fuzzy Search Implementation** +Combined PostgreSQL Full-Text Search + Trigram for optimal results: +```sql +-- FTS for exact word matching +search_vector @@ websearch_to_tsquery('simple', 'greta') +-- Trigram for typo tolerance +word_similarity('gre', first_name) > 0.2 +-- Substring for email/IDs +email ILIKE '%greta%' +``` + +### Key Learnings + +#### 1. Ash `manage_relationship` Internals +**Critical Discovery:** During validation, relationship data lives in `changeset.relationships`, NOT `changeset.attributes`: + +```elixir +# During validation (manage_relationship processing): +changeset.relationships.member = [{[%{id: "uuid"}], opts}] +changeset.attributes.member_id = nil # Still nil! + +# After action completes: +changeset.attributes.member_id = "uuid" # Now set +``` + +**Solution:** Extract member_id from both sources: +```elixir +defp get_member_id_from_changeset(changeset) do + case Map.get(changeset.relationships, :member) do + [{[%{id: id}], _opts}] -> id # New link + _ -> Ash.Changeset.get_attribute(changeset, :member_id) # Existing + end +end +``` + +**Impact:** Fixed email validation false positives when linking user+member with identical emails. + +#### 2. LiveView + JavaScript Integration Patterns + +**When to use JavaScript:** +- ✅ Direct DOM manipulation (autocomplete, input values) +- ✅ Browser APIs (clipboard, geolocation) +- ✅ Third-party libraries + +**When NOT to use JavaScript:** +- ❌ Form submissions +- ❌ Simple show/hide logic +- ❌ Server-side data fetching + +**Pattern:** +```elixir +socket |> push_event("event-name", %{key: value}) +``` +```javascript +window.addEventListener("phx:event-name", (e) => { /* handle */ }) +``` + +#### 3. PostgreSQL Trigram Search +Requires `pg_trgm` extension with GIN indexes: +```sql +CREATE INDEX members_first_name_trgm_idx + ON members USING GIN(first_name gin_trgm_ops); +``` +Supports: +- Typo tolerance: "Gret" finds "Greta" +- Partial matching: "Mit" finds "Mitglied" +- Substring: "exam" finds "example.com" + +#### 4. Test-Driven Development for Bug Fixes +Effective workflow: +1. Write test that reproduces bug (should fail) +2. Implement minimal fix +3. Verify test passes +4. Refactor while green + +**Result:** 355 tests passing, 100% backend coverage for new features. + +### Files Changed + +**Backend:** +- `lib/membership/member.ex` - `:available_for_linking` action with fuzzy search +- `lib/mv/accounts/user/validations/email_not_used_by_other_member.ex` - Relationship change extraction +- `lib/mv_web/live/user_live/form.ex` - Event handlers, state management + +**Frontend:** +- `assets/js/app.js` - Input value hook (6 lines) +- `priv/gettext/**/*.po` - 10 new translation keys (DE/EN) + +**Tests (NEW):** +- `test/membership/member_fuzzy_search_linking_test.exs` +- `test/accounts/user_member_linking_email_test.exs` +- `test/mv_web/user_live/form_member_linking_ui_test.exs` + +### Deployment Notes +- **Assets:** Requires `cd assets && npm run build` +- **Database:** No migrations (uses existing indexes) +- **Config:** No changes required + +--- + ## Conclusion This project demonstrates a modern Phoenix application built with: diff --git a/docs/roles-and-permissions-architecture.md b/docs/roles-and-permissions-architecture.md new file mode 100644 index 0000000..fa45d86 --- /dev/null +++ b/docs/roles-and-permissions-architecture.md @@ -0,0 +1,2502 @@ +# Roles and Permissions Architecture - Technical Specification + +**Version:** 2.0 (Clean Rewrite) +**Date:** 2025-01-13 +**Status:** Ready for Implementation +**Related Documents:** +- [Overview](./roles-and-permissions-overview.md) - High-level concepts for stakeholders +- [Implementation Plan](./roles-and-permissions-implementation-plan.md) - Step-by-step implementation guide + +--- + +## Table of Contents + +- [Overview](#overview) +- [Requirements Analysis](#requirements-analysis) +- [Selected Architecture](#selected-architecture) +- [Database Schema (MVP)](#database-schema-mvp) +- [Permission System Design (MVP)](#permission-system-design-mvp) +- [Resource Policies](#resource-policies) +- [Page Permission System](#page-permission-system) +- [UI-Level Authorization](#ui-level-authorization) +- [Special Cases](#special-cases) +- [User-Member Linking](#user-member-linking) +- [Future: Phase 2 - Field-Level Permissions](#future-phase-2---field-level-permissions) +- [Future: Phase 3 - Database-Backed Permissions](#future-phase-3---database-backed-permissions) +- [Migration Strategy](#migration-strategy) +- [Security Considerations](#security-considerations) +- [Appendix](#appendix) + +--- + +## Overview + +This document provides the complete technical specification for the **Roles and Permissions system** in the Mila membership management application. The system controls who can access what data and which actions they can perform. + +### Key Design Principles + +1. **Security First:** Authorization is enforced at multiple layers (database policies, page access, UI rendering) +2. **Performance:** MVP uses hardcoded permissions for < 1 microsecond checks +3. **Maintainability:** Clear separation between roles (data) and permissions (logic) +4. **Extensibility:** Clean migration path to database-backed permissions (Phase 3) +5. **User Experience:** Consistent authorization across backend and frontend +6. **Test-Driven:** All components fully tested with behavior-focused tests + +### Architecture Approach + +**MVP (Phase 1) - Hardcoded Permission Sets:** +- Permission logic in Elixir module (`Mv.Authorization.PermissionSets`) +- Role data in database (`roles` table) +- Roles reference permission sets by name (string) +- Zero database queries for permission checks +- Implementation time: 2-3 weeks + +**Future (Phase 2) - Field-Level Permissions:** +- Extend PermissionSets with field-level granularity +- Ash Calculations for read filtering +- Custom Validations for write protection +- No database schema changes + +**Future (Phase 3) - Database-Backed Permissions:** +- Move permission data to database tables +- Runtime permission configuration +- ETS cache for performance +- Migration from hardcoded module + +--- + +## Requirements Analysis + +### Core Requirements + +**1. Predefined Permission Sets** + +Four hardcoded permission sets that define access patterns: + +- **own_data** - User can only access their own data (default for members) +- **read_only** - Read access to all member data, no modifications +- **normal_user** - Create/Read/Update on members (no delete), full CRUD on custom fields +- **admin** - Unrestricted access including user/role management + +**2. Roles Stored in Database** + +Five predefined roles stored in the `roles` table: + +- **Mitglied** (Member) → uses "own_data" permission set +- **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 + +**3. Resource-Level Permissions** + +Control CRUD operations on: +- User (credentials, profile) +- Member (member data) +- Property (custom field values) +- PropertyType (custom field definitions) +- Role (role management) + +**4. Page-Level Permissions** + +Control access to LiveView pages: +- Index pages (list views) +- Show pages (detail views) +- Form pages (create/edit) +- Admin pages + +**5. Granular Scopes** + +Three scope levels for permissions: +- **:own** - Only records where `record.id == user.id` (for User resource) +- **:linked** - Only records linked to user via relationships + - Member: `member.user_id == user.id` + - Property: `property.member.user_id == user.id` +- **:all** - All records, no filtering + +**6. Special Cases** + +- **Own Credentials:** Every user can always read/update their own credentials +- **Linked Member Email:** Only admins can edit email of member linked to user +- **System Roles:** "Mitglied" role cannot be deleted (is_system_role flag) +- **User-Member Linking:** Only admins can link/unlink users and members + +**7. UI Consistency** + +- UI elements (buttons, links) only shown if user has permission +- Page access controlled before LiveView mounts +- Consistent authorization logic between backend and frontend + +--- + +## Selected Architecture + +### Approach: Hardcoded Permission Sets with Database Roles + +**Core Concept:** + +``` +PermissionSets Module (hardcoded in code) + ↓ (referenced by permission_set_name) +Role (stored in DB: "Vorstand" → "read_only") + ↓ (assigned to user via role_id) +User (each user has one role) +``` + +**Why This Approach?** + +✅ **Fast Implementation:** 2-3 weeks vs. 4-5 weeks for DB-backed +✅ **Maximum Performance:** < 1 microsecond per check (pure function call) +✅ **Zero DB Overhead:** No permission queries, no joins, no cache needed +✅ **Git-Tracked Changes:** All permission changes in version control +✅ **Deterministic Testing:** No DB setup, purely functional tests +✅ **Clear Migration Path:** Well-defined Phase 3 for DB-backed permissions + +**Trade-offs:** + +⚠️ **Deployment Required:** Permission changes need code deployment +⚠️ **Four Fixed Sets:** Cannot add new permission sets without code change +✔️ **Acceptable for MVP:** Requirements specify 4 fixed sets, rare changes expected + +### System Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Authorization System │ +└─────────────────────────────────────────────────────────────┘ + +┌──────────────────┐ +│ LiveView │ +│ (UI Layer) │ +└────────┬─────────┘ + │ + │ 1. Page Access Check + ↓ +┌──────────────────────────────────┐ +│ CheckPagePermission Plug │ +│ - Reads PermissionSets module │ +│ - Matches page pattern │ +│ - Redirects if unauthorized │ +└────────┬─────────────────────────┘ + │ + │ 2. UI Element Check + ↓ +┌──────────────────────────────────┐ +│ MvWeb.Authorization │ +│ - can?/3 │ +│ - can_access_page?/2 │ +│ - Uses PermissionSets module │ +└────────┬─────────────────────────┘ + │ + │ 3. Resource Action + ↓ +┌──────────────────────────────────┐ +│ Ash Resource (Member, User...) │ +│ - Policies block │ +└────────┬─────────────────────────┘ + │ + │ 4. Policy Evaluation + ↓ +┌──────────────────────────────────┐ +│ HasPermission Policy Check │ +│ - Reads actor.role │ +│ - Calls PermissionSets.get_permissions/1 │ +│ - Applies scope filter │ +└────────┬─────────────────────────┘ + │ + │ 5. Permission Lookup + ↓ +┌──────────────────────────────────┐ +│ PermissionSets Module │ +│ (Hardcoded in code) │ +│ - get_permissions/1 │ +│ - Returns {resources, pages} │ +└──────────────────────────────────┘ + +┌──────────────────────────────────┐ +│ Database │ +│ - roles table │ +│ - users.role_id → roles.id │ +└──────────────────────────────────┘ +``` + +**Authorization Flow:** + +1. **Page Request:** Plug checks if user can access page +2. **UI Rendering:** Helper checks which buttons/links to show +3. **User Action:** Ash receives action request (create, read, update, destroy) +4. **Policy Check:** `HasPermission` evaluates permission +5. **Permission Lookup:** Reads from `PermissionSets` module (in-memory) +6. **Scope Application:** Filters query based on scope (:own, :linked, :all) +7. **Result:** Action succeeds or fails with Forbidden error + +--- + +## Database Schema (MVP) + +### Overview + +The MVP requires **only ONE new table**: `roles` + +- ✅ Stores role definitions (name, description, permission_set_name) +- ✅ Links to users via foreign key +- ❌ NO permission tables (permissions are hardcoded) + +### Entity Relationship Diagram + +``` +┌─────────────────────────────────┐ +│ users │ +├─────────────────────────────────┤ +│ id (PK, UUID) │ +│ email │ +│ hashed_password │ +│ role_id (FK → roles.id) ◄───┼──┐ +│ ... │ │ +└─────────────────────────────────┘ │ + │ + │ +┌─────────────────────────────────┐ │ +│ roles │ │ +├─────────────────────────────────┤ │ +│ id (PK, UUID) │──┘ +│ name (unique) │ +│ description │ +│ permission_set_name (String) │───┐ +│ is_system_role (Boolean) │ │ +│ inserted_at │ │ +│ updated_at │ │ +└─────────────────────────────────┘ │ + │ + │ References one of: +┌─────────────────────────────────┐ │ - "own_data" +│ PermissionSets Module │◄──┘ - "read_only" +│ (Hardcoded in Code) │ - "normal_user" +├─────────────────────────────────┤ - "admin" +│ get_permissions(:own_data) │ +│ get_permissions(:read_only) │ +│ get_permissions(:normal_user) │ +│ get_permissions(:admin) │ +└─────────────────────────────────┘ +``` + +### Table Definitions + +#### roles + +Stores role definitions that reference permission sets by name. + +```sql +CREATE TABLE roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL UNIQUE, + description TEXT, + permission_set_name VARCHAR(50) NOT NULL, + is_system_role BOOLEAN NOT NULL DEFAULT false, + inserted_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + + CONSTRAINT check_valid_permission_set + CHECK (permission_set_name IN ('own_data', 'read_only', 'normal_user', 'admin')) +); + +CREATE UNIQUE INDEX roles_name_index ON roles (name); +CREATE INDEX roles_permission_set_name_index ON roles (permission_set_name); +``` + +**Fields:** +- `name` - Display name (e.g., "Vorstand", "Admin") +- `description` - Human-readable description +- `permission_set_name` - References hardcoded permission set +- `is_system_role` - If true, role cannot be deleted (protects "Mitglied") + +**Constraints:** +- `name` must be unique +- `permission_set_name` must be one of 4 valid values +- System roles cannot be deleted (enforced in Ash resource) + +#### users (modified) + +Add foreign key to roles table. + +```sql +ALTER TABLE users + ADD COLUMN role_id UUID REFERENCES roles(id) ON DELETE RESTRICT; + +CREATE INDEX users_role_id_index ON users (role_id); +``` + +**ON DELETE RESTRICT:** Prevents deleting a role if users are assigned to it. + +### Seed Data + +Five predefined roles created during initial setup: + +```elixir +# priv/repo/seeds/authorization_seeds.exs + +roles = [ + %{ + name: "Mitglied", + description: "Default member role with access to own data only", + permission_set_name: "own_data", + is_system_role: true # Cannot be deleted! + }, + %{ + name: "Vorstand", + description: "Board member with read access to all member data", + permission_set_name: "read_only", + is_system_role: false + }, + %{ + name: "Kassenwart", + description: "Treasurer with full member and payment management", + permission_set_name: "normal_user", + is_system_role: false + }, + %{ + name: "Buchhaltung", + description: "Accounting with read-only access for auditing", + permission_set_name: "read_only", + is_system_role: false + }, + %{ + name: "Admin", + description: "Administrator with unrestricted access", + permission_set_name: "admin", + is_system_role: false + } +] + +# Create roles with idempotent logic +Enum.each(roles, fn role_data -> + case Ash.get(Mv.Authorization.Role, name: role_data.name) do + {:ok, existing_role} -> + # Update if exists + Ash.update!(existing_role, role_data) + {:error, _} -> + # Create if not exists + Ash.create!(Mv.Authorization.Role, role_data) + end +end) + +# Assign "Mitglied" role to users without role +mitglied_role = Ash.get!(Mv.Authorization.Role, name: "Mitglied") +users_without_role = Ash.read!(Mv.Accounts.User, filter: expr(is_nil(role_id))) + +Enum.each(users_without_role, fn user -> + Ash.update!(user, %{role_id: mitglied_role.id}) +end) +``` + +--- + +## Permission System Design (MVP) + +### PermissionSets Module + +**Location:** `lib/mv/authorization/permission_sets.ex` + +This module is the **single source of truth** for all permissions in the MVP. It defines what each permission set can do. + +#### Module Structure + +```elixir +defmodule Mv.Authorization.PermissionSets do + @moduledoc """ + Defines the four hardcoded permission sets for the application. + + Each permission set specifies: + - Resource permissions (what CRUD operations on which resources) + - Page permissions (which LiveView pages can be accessed) + - Scopes (own, linked, all) + + ## Permission Sets + + 1. **own_data** - Default for "Mitglied" role + - Can only access own user data and linked member/properties + - Cannot create new members or manage system + + 2. **read_only** - For "Vorstand" and "Buchhaltung" roles + - Can read all member data + - Cannot create, update, or delete + + 3. **normal_user** - For "Kassenwart" role + - Create/Read/Update members (no delete), full CRUD on properties + - Cannot manage property types or users + + 4. **admin** - For "Admin" role + - Unrestricted access to all resources + - Can manage users, roles, property types + + ## Usage + + # Get permissions for a role's permission set + permissions = PermissionSets.get_permissions(:admin) + + # Check if a permission set name is valid + PermissionSets.valid_permission_set?("read_only") # => true + + # Convert string to atom safely + {:ok, atom} = PermissionSets.permission_set_name_to_atom("own_data") + + ## Performance + + All functions are pure and compile-time. Permission lookups are < 1 microsecond. + """ + + @type scope :: :own | :linked | :all + @type action :: :read | :create | :update | :destroy + + @type resource_permission :: %{ + resource: String.t(), + action: action(), + scope: scope(), + granted: boolean() + } + + @type permission_set :: %{ + resources: [resource_permission()], + pages: [String.t()] + } + + @doc """ + Returns the list of all valid permission set names. + + ## Examples + + iex> PermissionSets.all_permission_sets() + [:own_data, :read_only, :normal_user, :admin] + """ + @spec all_permission_sets() :: [atom()] + def all_permission_sets do + [:own_data, :read_only, :normal_user, :admin] + end + + @doc """ + Returns permissions for the given permission set. + + ## Examples + + iex> permissions = PermissionSets.get_permissions(:admin) + iex> Enum.any?(permissions.resources, fn p -> + ...> p.resource == "User" and p.action == :destroy + ...> end) + true + + iex> PermissionSets.get_permissions(:invalid) + ** (FunctionClauseError) no function clause matching + """ + @spec get_permissions(atom()) :: permission_set() + + def get_permissions(:own_data) do + %{ + resources: [ + # User: Can always read/update own credentials + %{resource: "User", action: :read, scope: :own, granted: true}, + %{resource: "User", action: :update, scope: :own, granted: true}, + + # Member: Can read/update linked member + %{resource: "Member", action: :read, scope: :linked, granted: true}, + %{resource: "Member", action: :update, scope: :linked, granted: true}, + + # Property: Can read/update properties of linked member + %{resource: "Property", action: :read, scope: :linked, granted: true}, + %{resource: "Property", action: :update, scope: :linked, granted: true}, + + # PropertyType: Can read all (needed for forms) + %{resource: "PropertyType", action: :read, scope: :all, granted: true} + ], + pages: [ + "/", # Home page + "/profile", # Own profile + "/members/:id" # Linked member detail (filtered by policy) + ] + } + end + + def get_permissions(:read_only) do + %{ + resources: [ + # User: Can read/update own credentials only + %{resource: "User", action: :read, scope: :own, granted: true}, + %{resource: "User", action: :update, scope: :own, granted: true}, + + # Member: Can read all members, no modifications + %{resource: "Member", action: :read, scope: :all, granted: true}, + + # Property: Can read all properties + %{resource: "Property", action: :read, scope: :all, granted: true}, + + # PropertyType: Can read all + %{resource: "PropertyType", action: :read, scope: :all, granted: true} + ], + pages: [ + "/", + "/members", # Member list + "/members/:id", # Member detail + "/properties", # Property overview + "/profile" # Own profile + ] + } + end + + def get_permissions(:normal_user) do + %{ + resources: [ + # User: Can read/update own credentials only + %{resource: "User", action: :read, scope: :own, granted: true}, + %{resource: "User", action: :update, scope: :own, granted: true}, + + # Member: Full CRUD + %{resource: "Member", action: :read, scope: :all, granted: true}, + %{resource: "Member", action: :create, scope: :all, granted: true}, + %{resource: "Member", action: :update, scope: :all, granted: true}, + # Note: destroy intentionally omitted for safety + + # Property: Full CRUD + %{resource: "Property", action: :read, scope: :all, granted: true}, + %{resource: "Property", action: :create, scope: :all, granted: true}, + %{resource: "Property", action: :update, scope: :all, granted: true}, + %{resource: "Property", action: :destroy, scope: :all, granted: true}, + + # PropertyType: Read only (admin manages definitions) + %{resource: "PropertyType", action: :read, scope: :all, granted: true} + ], + pages: [ + "/", + "/members", + "/members/new", # Create member + "/members/:id", + "/members/:id/edit", # Edit member + "/properties", + "/properties/new", + "/properties/:id/edit", + "/profile" + ] + } + end + + def get_permissions(:admin) do + %{ + resources: [ + # User: Full management including other users + %{resource: "User", action: :read, scope: :all, granted: true}, + %{resource: "User", action: :create, scope: :all, granted: true}, + %{resource: "User", action: :update, scope: :all, granted: true}, + %{resource: "User", action: :destroy, scope: :all, granted: true}, + + # Member: Full CRUD + %{resource: "Member", action: :read, scope: :all, granted: true}, + %{resource: "Member", action: :create, scope: :all, granted: true}, + %{resource: "Member", action: :update, scope: :all, granted: true}, + %{resource: "Member", action: :destroy, scope: :all, granted: true}, + + # Property: Full CRUD + %{resource: "Property", action: :read, scope: :all, granted: true}, + %{resource: "Property", action: :create, scope: :all, granted: true}, + %{resource: "Property", action: :update, scope: :all, granted: true}, + %{resource: "Property", action: :destroy, scope: :all, granted: true}, + + # PropertyType: Full CRUD (admin manages custom field definitions) + %{resource: "PropertyType", action: :read, scope: :all, granted: true}, + %{resource: "PropertyType", action: :create, scope: :all, granted: true}, + %{resource: "PropertyType", action: :update, scope: :all, granted: true}, + %{resource: "PropertyType", action: :destroy, scope: :all, granted: true}, + + # Role: Full CRUD (admin manages roles) + %{resource: "Role", action: :read, scope: :all, granted: true}, + %{resource: "Role", action: :create, scope: :all, granted: true}, + %{resource: "Role", action: :update, scope: :all, granted: true}, + %{resource: "Role", action: :destroy, scope: :all, granted: true} + ], + pages: [ + "*" # Wildcard: Admin can access all pages + ] + } + end + + @doc """ + Checks if a permission set name (string or atom) is valid. + + ## Examples + + iex> PermissionSets.valid_permission_set?("admin") + true + + iex> PermissionSets.valid_permission_set?(:read_only) + true + + iex> PermissionSets.valid_permission_set?("invalid") + false + """ + @spec valid_permission_set?(String.t() | atom()) :: boolean() + def valid_permission_set?(name) when is_binary(name) do + case permission_set_name_to_atom(name) do + {:ok, _atom} -> true + {:error, _} -> false + end + end + + def valid_permission_set?(name) when is_atom(name) do + name in all_permission_sets() + end + + @doc """ + Converts a permission set name string to atom safely. + + ## Examples + + iex> PermissionSets.permission_set_name_to_atom("admin") + {:ok, :admin} + + iex> PermissionSets.permission_set_name_to_atom("invalid") + {:error, :invalid_permission_set} + """ + @spec permission_set_name_to_atom(String.t()) :: {:ok, atom()} | {:error, :invalid_permission_set} + def permission_set_name_to_atom(name) when is_binary(name) do + atom = String.to_existing_atom(name) + if valid_permission_set?(atom) do + {:ok, atom} + else + {:error, :invalid_permission_set} + end + rescue + ArgumentError -> {:error, :invalid_permission_set} + end +end +``` + +#### Permission Matrix + +Quick reference table showing what each permission set allows: + +| Resource | own_data | read_only | normal_user | admin | +|----------|----------|-----------|-------------|-------| +| **User** (own) | R, U | R, U | R, U | R, U | +| **User** (all) | - | - | - | R, C, U, D | +| **Member** (linked) | R, U | - | - | - | +| **Member** (all) | - | R | R, C, U | R, C, U, D | +| **Property** (linked) | R, U | - | - | - | +| **Property** (all) | - | R | R, C, U, D | R, C, U, D | +| **PropertyType** (all) | R | R | R | R, C, U, D | +| **Role** (all) | - | - | - | R, C, U, D | + +**Legend:** R=Read, C=Create, U=Update, D=Destroy + +### HasPermission Policy Check + +**Location:** `lib/mv/authorization/checks/has_permission.ex` + +This is a custom Ash Policy Check that evaluates permissions from the `PermissionSets` module. + +```elixir +defmodule Mv.Authorization.Checks.HasPermission do + @moduledoc """ + Custom Ash Policy Check that evaluates permissions from the PermissionSets module. + + This check: + 1. Reads the actor's role and permission_set_name + 2. Looks up permissions from PermissionSets.get_permissions/1 + 3. Finds matching permission for current resource + action + 4. Applies scope filter (:own, :linked, :all) + + ## Usage in Ash Resource + + policies do + policy action_type(:read) do + authorize_if Mv.Authorization.Checks.HasPermission + end + end + + ## Scope Behavior + + - **:all** - Authorizes without filtering (returns all records) + - **:own** - Filters to records where record.id == actor.id + - **:linked** - Filters based on resource type: + - Member: member.user_id == actor.id + - Property: property.member.user_id == actor.id (traverses relationship!) + + ## Error Handling + + Returns `{:error, reason}` for: + - Missing actor + - Actor without role + - Invalid permission_set_name + - No matching permission found + + All errors result in Forbidden (policy fails). + """ + + use Ash.Policy.Check + require Ash.Query + import Ash.Expr + alias Mv.Authorization.PermissionSets + + @impl true + def describe(_opts) do + "checks if actor has permission via their role's permission set" + end + + @impl true + def match?(actor, %{resource: resource, action: %{name: action}}, _opts) do + with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor, + {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), + permissions <- PermissionSets.get_permissions(ps_atom), + resource_name <- get_resource_name(resource) do + check_permission(permissions.resources, resource_name, action, actor, resource_name) + else + %{role: nil} -> + log_auth_failure(actor, resource, action, "no role assigned") + {:error, :no_role} + + %{role: %{permission_set_name: nil}} -> + log_auth_failure(actor, resource, action, "role has no permission_set_name") + {:error, :no_permission_set} + + {:error, :invalid_permission_set} = error -> + log_auth_failure(actor, resource, action, "invalid permission_set_name") + error + + _ -> + log_auth_failure(actor, resource, action, "no actor or missing data") + {:error, :no_permission} + end + end + + # Extract resource name from module (e.g., Mv.Membership.Member -> "Member") + defp get_resource_name(resource) when is_atom(resource) do + resource |> Module.split() |> List.last() + end + + # Find matching permission and apply scope + defp check_permission(resource_perms, resource_name, action, actor, resource_module_name) do + case Enum.find(resource_perms, fn perm -> + perm.resource == resource_name and + perm.action == action and + perm.granted + end) do + nil -> + {:error, :no_permission} + + perm -> + apply_scope(perm.scope, actor, resource_name) + end + end + + # Scope: all - No filtering, access to all records + defp apply_scope(:all, _actor, _resource) do + :authorized + end + + # Scope: own - Filter to records where record.id == actor.id + # Used for User resource (users can access their own user record) + defp apply_scope(:own, actor, _resource) do + {:filter, expr(id == ^actor.id)} + end + + # Scope: linked - Filter based on user_id relationship (resource-specific!) + defp apply_scope(:linked, actor, resource_name) do + case resource_name do + "Member" -> + # Member.user_id == actor.id (direct relationship) + {:filter, expr(user_id == ^actor.id)} + + "Property" -> + # Property.member.user_id == actor.id (traverse through member!) + {:filter, expr(member.user_id == ^actor.id)} + + _ -> + # Fallback for other resources: try direct user_id + {:filter, expr(user_id == ^actor.id)} + end + end + + # Log authorization failures for debugging + defp log_auth_failure(actor, resource, action, reason) do + require Logger + + actor_id = if is_map(actor), do: Map.get(actor, :id), else: "nil" + resource_name = get_resource_name(resource) + + Logger.debug(""" + Authorization failed: + Actor: #{actor_id} + Resource: #{resource_name} + Action: #{action} + Reason: #{reason} + """) + end +end +``` + +**Key Design Decisions:** + +1. **Resource-Specific :linked Scope:** Property needs to traverse `member` relationship to check `user_id` +2. **Error Handling:** All errors log for debugging but return generic forbidden to user +3. **Module Name Extraction:** Uses `Module.split() |> List.last()` to match against PermissionSets strings +4. **Pure Function:** No side effects, deterministic, easily testable + +--- + +## Resource Policies + +Each Ash resource defines policies that use the `HasPermission` check. This section documents the policy structure for each resource. + +### General Policy Pattern + +**All resources follow this pattern:** + +```elixir +policies do + # 1. Special cases first (most specific) + policy action_type(:read) do + authorize_if expr(condition_for_special_case) + end + + # 2. General authorization (uses PermissionSets) + policy action_type([:read, :create, :update, :destroy]) do + authorize_if Mv.Authorization.Checks.HasPermission + end + + # 3. Default: Forbid + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() + end +end +``` + +**Policy Order Matters!** Ash evaluates policies top-to-bottom, first match wins. + +### User Resource Policies + +**Location:** `lib/mv/accounts/user.ex` + +**Special Case:** Users can ALWAYS read/update their own credentials, regardless of role. + +```elixir +defmodule Mv.Accounts.User do + use Ash.Resource, ... + + policies do + # SPECIAL CASE: Users can always access their own account + # This takes precedence over permission checks + policy action_type([:read, :update]) do + description "Users can always read and update their own account" + authorize_if expr(id == ^actor(:id)) + end + + # GENERAL: Other operations require permission + # (e.g., admin reading/updating other users, admin destroying users) + policy action_type([:read, :create, :update, :destroy]) do + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission + end + + # DEFAULT: Forbid if no policy matched + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() + end + end + + # ... +end +``` + +**Permission Matrix:** + +| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | +|--------|----------|----------|------------|-------------|-------| +| Read own | ✅ (special) | ✅ (special) | ✅ (special) | ✅ (special) | ✅ | +| Update own | ✅ (special) | ✅ (special) | ✅ (special) | ✅ (special) | ✅ | +| Read others | ❌ | ❌ | ❌ | ❌ | ✅ | +| Update others | ❌ | ❌ | ❌ | ❌ | ✅ | +| Create | ❌ | ❌ | ❌ | ❌ | ✅ | +| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ | + +### Member Resource Policies + +**Location:** `lib/mv/membership/member.ex` + +**Special Case:** Users can always access their linked member (where `member.user_id == user.id`). + +```elixir +defmodule Mv.Membership.Member do + use Ash.Resource, ... + + policies do + # SPECIAL CASE: Users can always access their linked member + policy action_type([:read, :update]) do + description "Users can access member linked to their account" + authorize_if expr(user_id == ^actor(:id)) + end + + # GENERAL: Check permissions from role + policy action_type([:read, :create, :update, :destroy]) do + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission + end + + # DEFAULT: Forbid + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() + end + end + + # Custom validation for email editing (see Special Cases section) + validations do + validate changing(:email), on: :update do + validate &validate_linked_member_email_change/2 + end + end + + # ... +end +``` + +**Permission Matrix:** + +| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | +|--------|----------|----------|------------|-------------|-------| +| Read linked | ✅ (special) | ✅ (if linked) | ✅ | ✅ (if linked) | ✅ | +| Update linked | ✅ (special)* | ❌ | ✅* | ❌ | ✅ | +| Read all | ❌ | ✅ | ✅ | ✅ | ✅ | +| Create | ❌ | ❌ | ✅ | ❌ | ✅ | +| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ | + +*Email editing has additional validation (see Special Cases) + +### Property Resource Policies + +**Location:** `lib/mv/membership/property.ex` + +**Special Case:** Users can access properties of their linked member. + +```elixir +defmodule Mv.Membership.Property do + use Ash.Resource, ... + + policies do + # SPECIAL CASE: Users can access properties of their linked member + # Note: This traverses the member relationship! + policy action_type([:read, :update]) do + description "Users can access properties of their linked member" + authorize_if expr(member.user_id == ^actor(:id)) + end + + # GENERAL: Check permissions from role + policy action_type([:read, :create, :update, :destroy]) do + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission + end + + # DEFAULT: Forbid + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() + end + end + + # ... +end +``` + +**Permission Matrix:** + +| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | +|--------|----------|----------|------------|-------------|-------| +| Read linked | ✅ (special) | ✅ (if linked) | ✅ | ✅ (if linked) | ✅ | +| Update linked | ✅ (special) | ❌ | ✅ | ❌ | ✅ | +| Read all | ❌ | ✅ | ✅ | ✅ | ✅ | +| Create | ❌ | ❌ | ✅ | ❌ | ✅ | +| Destroy | ❌ | ❌ | ✅ | ❌ | ✅ | + +### PropertyType Resource Policies + +**Location:** `lib/mv/membership/property_type.ex` + +**No Special Cases:** All users can read, only admin can write. + +```elixir +defmodule Mv.Membership.PropertyType do + use Ash.Resource, ... + + policies do + # All authenticated users can read property types (needed for forms) + # Write operations are admin-only + policy action_type([:read, :create, :update, :destroy]) do + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission + end + + # DEFAULT: Forbid + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() + end + end + + # ... +end +``` + +**Permission Matrix:** + +| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | +|--------|----------|----------|------------|-------------|-------| +| Read | ✅ | ✅ | ✅ | ✅ | ✅ | +| Create | ❌ | ❌ | ❌ | ❌ | ✅ | +| Update | ❌ | ❌ | ❌ | ❌ | ✅ | +| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ | + +### Role Resource Policies + +**Location:** `lib/mv/authorization/role.ex` + +**Special Protection:** System roles cannot be deleted. + +```elixir +defmodule Mv.Authorization.Role do + use Ash.Resource, ... + + policies do + # Only admin can manage roles + policy action_type([:read, :create, :update, :destroy]) do + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission + end + + # DEFAULT: Forbid + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() + end + end + + # Prevent deletion of system roles + validations do + validate action(:destroy) do + validate fn _changeset, %{data: role} -> + if role.is_system_role do + {:error, "Cannot delete system role"} + else + :ok + end + end + end + end + + # Validate permission_set_name + validations do + validate attribute(:permission_set_name) do + validate fn _changeset, value -> + if PermissionSets.valid_permission_set?(value) do + :ok + else + {:error, "Invalid permission set name. Must be one of: #{Enum.join(PermissionSets.all_permission_sets(), ", ")}"} + end + end + end + end + + # ... +end +``` + +**Permission Matrix:** + +| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | +|--------|----------|----------|------------|-------------|-------| +| Read | ❌ | ❌ | ❌ | ❌ | ✅ | +| Create | ❌ | ❌ | ❌ | ❌ | ✅ | +| Update | ❌ | ❌ | ❌ | ❌ | ✅ | +| Destroy* | ❌ | ❌ | ❌ | ❌ | ✅ | + +*Cannot destroy if `is_system_role=true` + +--- + +## Page Permission System + +Page permissions control which LiveView pages a user can access. This is enforced **before** the LiveView mounts via a Phoenix Plug. + +### CheckPagePermission Plug + +**Location:** `lib/mv_web/plugs/check_page_permission.ex` + +This plug runs in the router pipeline and checks if the current user has permission to access the requested page. + +```elixir +defmodule MvWeb.Plugs.CheckPagePermission do + @moduledoc """ + Plug that checks if current user has permission to access the current page. + + ## How It Works + + 1. Extracts page path from conn (route template like "/members/:id") + 2. Gets current user from conn.assigns + 3. Gets user's permission_set_name from role + 4. Calls PermissionSets.get_permissions/1 to get allowed pages + 5. Matches requested path against allowed patterns + 6. If unauthorized: redirects to "/" with flash error + + ## Pattern Matching + + - Exact match: "/members" == "/members" + - Dynamic routes: "/members/:id" matches "/members/123" + - Wildcard: "*" matches everything (admin) + + ## Usage in Router + + pipeline :require_page_permission do + plug MvWeb.Plugs.CheckPagePermission + end + + scope "/members", MvWeb do + pipe_through [:browser, :require_authenticated_user, :require_page_permission] + + live "/", MemberLive.Index + live "/:id", MemberLive.Show + end + """ + + import Plug.Conn + import Phoenix.Controller + alias Mv.Authorization.PermissionSets + require Logger + + def init(opts), do: opts + + def call(conn, _opts) do + user = conn.assigns[:current_user] + page_path = get_page_path(conn) + + if has_page_permission?(user, page_path) do + conn + else + log_page_access_denied(user, page_path) + + conn + |> put_flash(:error, "You don't have permission to access this page.") + |> redirect(to: "/") + |> halt() + end + end + + # Extract page path from conn (route template preferred, fallback to request_path) + defp get_page_path(conn) do + case conn.private[:phoenix_route] do + {_plug, _opts, _pipe, route_template, _meta} -> + route_template + + _ -> + conn.request_path + end + end + + # Check if user has permission for page + defp has_page_permission?(nil, _page_path) do + false + end + + defp has_page_permission?(user, page_path) do + with %{role: %{permission_set_name: ps_name}} <- user, + {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), + permissions <- PermissionSets.get_permissions(ps_atom) do + page_matches?(permissions.pages, page_path) + else + _ -> false + end + end + + # Check if requested path matches any allowed pattern + defp page_matches?(allowed_pages, requested_path) do + Enum.any?(allowed_pages, fn pattern -> + cond do + # Wildcard: admin can access all pages + pattern == "*" -> + true + + # Exact match + pattern == requested_path -> + true + + # Dynamic route match (e.g., "/members/:id" matches "/members/123") + String.contains?(pattern, ":") -> + match_dynamic_route?(pattern, requested_path) + + # No match + true -> + false + end + end) + end + + # Match dynamic route pattern against actual path + defp match_dynamic_route?(pattern, path) do + pattern_segments = String.split(pattern, "/", trim: true) + path_segments = String.split(path, "/", trim: true) + + # Must have same number of segments + if length(pattern_segments) == length(path_segments) do + Enum.zip(pattern_segments, path_segments) + |> Enum.all?(fn {pattern_seg, path_seg} -> + # Dynamic segment (starts with :) matches anything + String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg + end) + else + false + end + end + + defp log_page_access_denied(user, page_path) do + user_id = if is_map(user), do: Map.get(user, :id), else: "nil" + role = if is_map(user), do: get_in(user, [:role, :name]), else: "nil" + + Logger.info(""" + Page access denied: + User: #{user_id} + Role: #{role} + Page: #{page_path} + """) + end +end +``` + +### Router Integration + +Add plug to protected routes: + +```elixir +defmodule MvWeb.Router do + use MvWeb, :router + + pipeline :require_page_permission do + plug MvWeb.Plugs.CheckPagePermission + end + + # Public routes (no authentication) + scope "/", MvWeb do + pipe_through :browser + + live "/", PageController, :home + get "/login", AuthController, :new + post "/login", AuthController, :create + end + + # Protected routes (authentication + page permission) + scope "/members", MvWeb do + pipe_through [:browser, :require_authenticated_user, :require_page_permission] + + live "/", MemberLive.Index, :index + live "/new", MemberLive.Form, :new + live "/:id", MemberLive.Show, :show + live "/:id/edit", MemberLive.Form, :edit + end + + # Admin routes + scope "/admin", MvWeb do + pipe_through [:browser, :require_authenticated_user, :require_page_permission] + + live "/roles", RoleLive.Index, :index + live "/roles/:id", RoleLive.Show, :show + end +end +``` + +### Page Permission Examples + +**Mitglied (own_data):** +- ✅ Can access: `/`, `/profile`, `/members/123` (if 123 is their linked member) +- ❌ Cannot access: `/members`, `/members/new`, `/admin/roles` + +**Vorstand (read_only):** +- ✅ Can access: `/`, `/members`, `/members/123`, `/properties`, `/profile` +- ❌ Cannot access: `/members/new`, `/members/123/edit`, `/admin/roles` + +**Kassenwart (normal_user):** +- ✅ Can access: `/`, `/members`, `/members/new`, `/members/123/edit`, `/properties`, `/profile` +- ❌ Cannot access: `/admin/roles`, `/admin/property_types/new` + +**Admin:** +- ✅ Can access: `*` (all pages, including `/admin/roles`) + +--- + +## UI-Level Authorization + +UI-level authorization ensures that users only see buttons, links, and form fields they have permission to use. This provides a consistent user experience and prevents confusing "forbidden" errors. + +### MvWeb.Authorization Helper Module + +**Location:** `lib/mv_web/authorization.ex` + +This module provides helper functions for conditional rendering in LiveView templates. + +```elixir +defmodule MvWeb.Authorization do + @moduledoc """ + UI-level authorization helpers for LiveView templates. + + These functions check if the current user has permission to perform actions + or access pages. They use the same PermissionSets module as the backend policies, + ensuring UI and backend authorization are consistent. + + ## Usage in Templates + + + <%= if can?(@current_user, :create, Mv.Membership.Member) do %> + <.link patch={~p"/members/new"}>New Member + <% end %> + + + <%= if can?(@current_user, :update, @member) do %> + <.button>Edit + <% end %> + + + <%= if can_access_page?(@current_user, "/admin/roles") do %> + <.link navigate="/admin/roles">Manage Roles + <% end %> + + ## Performance + + All checks are pure function calls using the hardcoded PermissionSets module. + No database queries, < 1 microsecond per check. + """ + + alias Mv.Authorization.PermissionSets + + @doc """ + Checks if user has permission for an action on a resource (atom). + + ## Examples + + iex> admin = %{role: %{permission_set_name: "admin"}} + iex> can?(admin, :create, Mv.Membership.Member) + true + + iex> mitglied = %{role: %{permission_set_name: "own_data"}} + iex> can?(mitglied, :create, Mv.Membership.Member) + false + """ + @spec can?(map() | nil, atom(), atom()) :: boolean() + def can?(nil, _action, _resource), do: false + + def can?(user, action, resource) when is_atom(action) and is_atom(resource) do + with %{role: %{permission_set_name: ps_name}} <- user, + {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), + permissions <- PermissionSets.get_permissions(ps_atom) do + resource_name = get_resource_name(resource) + + Enum.any?(permissions.resources, fn perm -> + perm.resource == resource_name and + perm.action == action and + perm.granted + end) + else + _ -> false + end + end + + @doc """ + Checks if user has permission for an action on a specific record (struct). + + Applies scope checking: + - :own - record.id == user.id + - :linked - record.user_id == user.id (or traverses relationships) + - :all - always true + + ## Examples + + iex> user = %{id: "user-123", role: %{permission_set_name: "own_data"}} + iex> member = %Member{id: "member-456", user_id: "user-123"} + iex> can?(user, :update, member) + true + + iex> other_member = %Member{id: "member-789", user_id: "other-user"} + iex> can?(user, :update, other_member) + false + """ + @spec can?(map() | nil, atom(), struct()) :: boolean() + def can?(nil, _action, _record), do: false + + def can?(user, action, %resource{} = record) when is_atom(action) do + with %{role: %{permission_set_name: ps_name}} <- user, + {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), + permissions <- PermissionSets.get_permissions(ps_atom) do + resource_name = get_resource_name(resource) + + # Find matching permission + matching_perm = Enum.find(permissions.resources, fn perm -> + perm.resource == resource_name and + perm.action == action and + perm.granted + end) + + case matching_perm do + nil -> false + perm -> check_scope(perm.scope, user, record, resource_name) + end + else + _ -> false + end + end + + @doc """ + Checks if user can access a specific page. + + ## Examples + + iex> admin = %{role: %{permission_set_name: "admin"}} + iex> can_access_page?(admin, "/admin/roles") + true + + iex> mitglied = %{role: %{permission_set_name: "own_data"}} + iex> can_access_page?(mitglied, "/members") + false + """ + @spec can_access_page?(map() | nil, String.t()) :: boolean() + def can_access_page?(nil, _page_path), do: false + + def can_access_page?(user, page_path) do + with %{role: %{permission_set_name: ps_name}} <- user, + {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), + permissions <- PermissionSets.get_permissions(ps_atom) do + page_matches?(permissions.pages, page_path) + else + _ -> false + end + end + + # Check if scope allows access to record + defp check_scope(:all, _user, _record, _resource_name), do: true + + defp check_scope(:own, user, record, _resource_name) do + record.id == user.id + end + + defp check_scope(:linked, user, record, resource_name) do + case resource_name do + "Member" -> + # Direct relationship: member.user_id + Map.get(record, :user_id) == user.id + + "Property" -> + # Need to traverse: property.member.user_id + # Note: In UI, property should have member preloaded + case Map.get(record, :member) do + %{user_id: member_user_id} -> member_user_id == user.id + _ -> false + end + + _ -> + # Fallback: check user_id + Map.get(record, :user_id) == user.id + end + end + + # Check if page path matches any allowed pattern + defp page_matches?(allowed_pages, requested_path) do + Enum.any?(allowed_pages, fn pattern -> + cond do + pattern == "*" -> true + pattern == requested_path -> true + String.contains?(pattern, ":") -> match_pattern?(pattern, requested_path) + true -> false + end + end) + end + + # Match dynamic route pattern + defp match_pattern?(pattern, path) do + pattern_segments = String.split(pattern, "/", trim: true) + path_segments = String.split(path, "/", trim: true) + + if length(pattern_segments) == length(path_segments) do + Enum.zip(pattern_segments, path_segments) + |> Enum.all?(fn {pattern_seg, path_seg} -> + String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg + end) + else + false + end + end + + # Extract resource name from module + defp get_resource_name(resource) when is_atom(resource) do + resource |> Module.split() |> List.last() + end +end +``` + +### Import in mv_web.ex + +Make helpers available to all LiveViews: + +```elixir +defmodule MvWeb do + # ... + + def html_helpers do + quote do + # ... existing helpers ... + + # Authorization helpers + import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2] + end + end + + # ... +end +``` + +### UI Examples + +**Navbar with conditional links:** + +```heex + + +``` + +**Index page with conditional "New" button:** + +```heex + + + + + + <%= for member <- @members do %> + + + + + <% end %> +
<%= member.name %> + + <%= if can?(@current_user, :update, member) do %> + <.link patch={~p"/members/#{member.id}/edit"}>Edit + <% end %> + + + <%= if can?(@current_user, :destroy, member) do %> + <.button phx-click="delete" phx-value-id={member.id}>Delete + <% end %> +
+``` + +**Show page with conditional edit button:** + +```heex + +
+

<%= @member.name %>

+ +
+
Email
+
<%= @member.email %>
+ +
Address
+
<%= @member.address %>
+
+ + + <%= if can?(@current_user, :update, @member) do %> + <.link patch={~p"/members/#{@member.id}/edit"} class="btn-primary"> + Edit Member + + <% end %> +
+``` + +--- + +## Special Cases + +### 1. Own Credentials Access + +**Requirement:** Every user can ALWAYS read and update their own credentials (email, password), regardless of their role. + +**Implementation:** + +Policy in `User` resource places this check BEFORE the general `HasPermission` check: + +```elixir +policies do + # SPECIAL CASE: Takes precedence over role permissions + policy action_type([:read, :update]) do + description "Users can always read and update their own account" + authorize_if expr(id == ^actor(:id)) + end + + # GENERAL: For other operations (e.g., admin reading other users) + policy action_type([:read, :create, :update, :destroy]) do + authorize_if Mv.Authorization.Checks.HasPermission + end +end +``` + +**Why this works:** +- Ash evaluates policies top-to-bottom +- First matching policy wins +- Special case catches own-account access before checking permissions +- Even a user with `own_data` (no admin permissions) can update their credentials + +### 2. Linked Member Email Editing + +**Requirement:** Only administrators can edit the email of a member that is linked to a user (has `user_id` set). This prevents breaking email synchronization. + +**Implementation:** + +Custom validation in `Member` resource: + +```elixir +defmodule Mv.Membership.Member do + use Ash.Resource, ... + + validations do + # Only run when email is being changed + validate changing(:email), on: :update do + validate &validate_linked_member_email_change/2 + end + end + + defp validate_linked_member_email_change(changeset, _context) do + member = changeset.data + actor = changeset.context[:actor] + + # If member is not linked to user, allow change + if is_nil(member.user_id) do + :ok + else + # Member is linked - check if actor is admin + if has_admin_permission?(actor) do + :ok + else + {:error, "Only administrators can change email for members linked to user accounts"} + end + end + end + + defp has_admin_permission?(nil), do: false + + defp has_admin_permission?(actor) do + with %{role: %{permission_set_name: ps_name}} <- actor, + {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), + permissions <- PermissionSets.get_permissions(ps_atom) do + # Check if actor has User.update permission with scope :all (admin privilege) + Enum.any?(permissions.resources, fn perm -> + perm.resource == "User" and + perm.action == :update and + perm.scope == :all and + perm.granted + end) + else + _ -> false + end + end +end +``` + +**Why this is needed:** +- Member email and User email are kept in sync +- If a non-admin changes linked member email, it could create inconsistency +- Validation runs AFTER policy check, so normal_user can update member +- But validation blocks email field specifically if member is linked + +### 3. System Role Protection + +**Requirement:** The "Mitglied" role cannot be deleted because it's the default role for all users. + +**Implementation:** + +Flag + validation in `Role` resource: + +```elixir +defmodule Mv.Authorization.Role do + use Ash.Resource, ... + + attributes do + # ... + attribute :is_system_role, :boolean, default: false + end + + validations do + validate action(:destroy) do + validate fn _changeset, %{data: role} -> + if role.is_system_role do + {:error, "Cannot delete system role. System roles are required for the application to function."} + else + :ok + end + end + end + end +end +``` + +**Seeds set the flag:** + +```elixir +%{ + name: "Mitglied", + permission_set_name: "own_data", + is_system_role: true # <-- Protected! +} +``` + +**UI hides delete button:** + +```heex +<%= if can?(@current_user, :destroy, role) and not role.is_system_role do %> + <.button phx-click="delete">Delete +<% end %> +``` + +### 4. User Without Role (Edge Case) + +**Requirement:** Users without a role should be denied all access (except logout). + +**Implementation:** + +**Default Assignment:** Seeds assign "Mitglied" role to all existing users + +```elixir +# In authorization_seeds.exs +mitglied_role = Ash.get!(Role, name: "Mitglied") +users_without_role = Ash.read!(User, filter: expr(is_nil(role_id))) + +Enum.each(users_without_role, fn user -> + Ash.update!(user, %{role_id: mitglied_role.id}) +end) +``` + +**Runtime Handling:** All authorization checks handle missing role gracefully + +```elixir +# In HasPermission check +def match?(actor, %{resource: resource, action: action}, _opts) do + with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor, + # ... + else + %{role: nil} -> + {:error, :no_role} # User has no role -> forbidden + + _ -> + {:error, :no_permission} + end +end +``` + +**Result:** User with no role sees empty UI, cannot access pages, gets forbidden on all actions. + +### 5. Invalid permission_set_name (Edge Case) + +**Requirement:** If a role has an invalid `permission_set_name`, fail gracefully without crashing. + +**Implementation:** + +**Prevention:** Validation on Role resource + +```elixir +validations do + validate attribute(:permission_set_name) do + validate fn _changeset, value -> + if PermissionSets.valid_permission_set?(value) do + :ok + else + {:error, "Invalid permission set name. Must be one of: #{Enum.join(PermissionSets.all_permission_sets(), ", ")}"} + end + end + end +end +``` + +**Runtime Handling:** All lookups check validity + +```elixir +# In PermissionSets module +def permission_set_name_to_atom(name) when is_binary(name) do + atom = String.to_existing_atom(name) + if valid_permission_set?(atom) do + {:ok, atom} + else + {:error, :invalid_permission_set} + end +rescue + ArgumentError -> {:error, :invalid_permission_set} +end +``` + +**Result:** Invalid `permission_set_name` → authorization fails → forbidden (safe default). + +--- + +## User-Member Linking + +### Requirement + +Users and Members are separate entities that can be linked. Special rules: +- Only admins can link/unlink users and members +- A user cannot link themselves to an existing member +- A user CAN create a new member and be directly linked to it (self-service) + +### Approach: Separate Ash Actions + +We use **different Ash actions** to enforce different policies: + +1. **`create_member_for_self`** - User creates member and links to themselves +2. **`create_member`** - Admin creates member for any user (or unlinked) +3. **`link_member_to_user`** - Admin links existing member to user +4. **`unlink_member_from_user`** - Admin removes user link +5. **`update`** - Standard update (cannot change `user_id`) + +### Implementation + +```elixir +defmodule Mv.Membership.Member do + use Ash.Resource, ... + + actions do + # SELF-SERVICE: User creates member and links to self + create :create_member_for_self do + description "User creates a new member and links it to their own account" + + accept [:name, :email, :address, ...] # All fields except user_id + + # Automatically set user_id to actor + change set_attribute(:user_id, actor(:id)) + + # Prevent creating multiple members for same user (optional business rule) + validate fn changeset, _context -> + actor_id = get_change(changeset, :user_id) + + case Ash.read(Member, filter: expr(user_id == ^actor_id)) do + {:ok, []} -> :ok # No existing member, allow + {:ok, [_member | _]} -> {:error, "You already have a member profile"} + {:error, _} -> :ok + end + end + end + + # ADMIN: Create member with optional user link + create :create_member do + description "Admin creates a new member, optionally linked to a user" + + accept [:name, :email, :address, ..., :user_id] # Admin can set user_id + end + + # ADMIN: Link existing member to user + update :link_member_to_user do + description "Admin links an existing member to a user account" + + accept [:user_id] + + validate fn changeset, _context -> + member = changeset.data + + # Cannot link if already linked + if is_nil(member.user_id) do + :ok + else + {:error, "Member is already linked to a user"} + end + end + end + + # ADMIN: Remove user link from member + update :unlink_member_from_user do + description "Admin removes user link from member" + + change set_attribute(:user_id, nil) + end + + # STANDARD UPDATE: Cannot change user_id + update :update do + description "Update member data (cannot change user link)" + + accept [:name, :email, :address, ...] # user_id NOT in accept list + end + end + + policies do + # Self-service member creation + policy action(:create_member_for_self) do + description "Any authenticated user can create member for themselves" + authorize_if actor_present() + end + + # Admin-only actions + policy action([:create_member, :link_member_to_user, :unlink_member_from_user]) do + description "Only admin can manage user-member links" + authorize_if Mv.Authorization.Checks.HasPermission + end + + # Standard actions (regular permission check) + policy action([:read, :update, :destroy]) do + authorize_if Mv.Authorization.Checks.HasPermission + end + end +end +``` + +### UI Examples + +**User Self-Service:** + +```heex + +<%= if is_nil(@current_user.member_id) do %> + <.link navigate="/members/new_for_self"> + Create My Member Profile + +<% end %> + + +<.simple_form for={@form} phx-submit="create_for_self"> + <.input field={@form[:name]} label="Name" /> + <.input field={@form[:email]} label="Email" /> + <.input field={@form[:address]} label="Address" /> + + + + <:actions> + <.button>Create My Profile + + +``` + +**Admin Interface:** + +```heex + +<%= if can?(@current_user, :link_member_to_user, @member) do %> + <%= if is_nil(@member.user_id) do %> + + <.form for={@link_form} phx-submit="link_to_user"> + <.input field={@link_form[:user_id]} type="select" label="Link to User" options={@users} /> + <.button>Link to User + + <% else %> + + <.button phx-click="unlink_from_user" phx-value-id={@member.id}> + Unlink from User (<%= @member.user.email %>) + + <% end %> +<% end %> +``` + +### Why Separate Actions? + +✅ **Clear Intent:** Action name communicates what's happening +✅ **Precise Policies:** Different policies for different operations +✅ **Better UX:** Separate UI flows for self-service vs. admin +✅ **Testable:** Each action can be tested independently +✅ **Idiomatic Ash:** Uses Ash's action system as designed + +--- + +## Future: Phase 2 - Field-Level Permissions + +**Status:** Not in MVP, planned for future enhancement + +**Goal:** Control which fields a user can read or write, beyond resource-level permissions. + +### Strategy + +**Extend PermissionSets module with `:fields` key:** + +```elixir +def get_permissions(:read_only) do + %{ + resources: [...], + pages: [...], + fields: [ + # Vorstand can read all member fields except sensitive payment info + %{ + resource: "Member", + action: :read, + fields: [:all], + excluded_fields: [:payment_method, :bank_account] + }, + + # Vorstand cannot write any member fields + %{ + resource: "Member", + action: :update, + fields: [] # Empty = no fields writable + } + ] + } +end +``` + +**Read Filtering via Ash Calculations:** + +```elixir +defmodule Mv.Membership.Member do + calculations do + calculate :filtered_fields, :map do + calculate fn members, context -> + actor = context[:actor] + + # Get allowed fields from PermissionSets + allowed_fields = get_allowed_read_fields(actor, "Member") + + # Filter fields + Enum.map(members, fn member -> + Map.take(member, allowed_fields) + end) + end + end + end +end +``` + +**Write Protection via Custom Validations:** + +```elixir +validations do + validate on: :update do + validate fn changeset, context -> + actor = context[:actor] + changed_fields = Map.keys(changeset.attributes) + + # Get allowed fields from PermissionSets + allowed_fields = get_allowed_write_fields(actor, "Member") + + # Check if any forbidden field is being changed + forbidden = Enum.reject(changed_fields, &(&1 in allowed_fields)) + + if Enum.empty?(forbidden) do + :ok + else + {:error, "You do not have permission to modify: #{Enum.join(forbidden, ", ")}"} + end + end + end +end +``` + +**Benefits:** +- ✅ No database schema changes +- ✅ Still uses hardcoded PermissionSets +- ✅ Granular control over sensitive fields +- ✅ Clear error messages + +**Estimated Effort:** 2-3 weeks + +--- + +## Future: Phase 3 - Database-Backed Permissions + +**Status:** Not in MVP, planned for future when runtime configuration is needed + +**Goal:** Move permission definitions from code to database for runtime configuration. + +### High-Level Design + +**New Tables:** + +```sql +CREATE TABLE permission_sets ( + id UUID PRIMARY KEY, + name VARCHAR(50) UNIQUE, + description TEXT, + is_system BOOLEAN +); + +CREATE TABLE permission_set_resources ( + id UUID PRIMARY KEY, + permission_set_id UUID REFERENCES permission_sets(id), + resource_name VARCHAR(100), + action VARCHAR(20), + scope VARCHAR(20), + granted BOOLEAN +); + +CREATE TABLE permission_set_pages ( + id UUID PRIMARY KEY, + permission_set_id UUID REFERENCES permission_sets(id), + page_pattern VARCHAR(255) +); +``` + +**Migration Strategy:** + +1. Create new tables +2. Seed from current `PermissionSets` module +3. Create new `HasResourcePermission` check that queries DB +4. Add ETS cache for performance +5. Replace `HasPermission` with `HasResourcePermission` in policies +6. Test thoroughly +7. Deploy +8. Eventually remove `PermissionSets` module + +**ETS Cache:** + +```elixir +defmodule Mv.Authorization.PermissionCache do + def get_permissions(permission_set_id) do + case :ets.lookup(:permission_cache, permission_set_id) do + [{^permission_set_id, permissions}] -> + permissions + + [] -> + permissions = load_from_db(permission_set_id) + :ets.insert(:permission_cache, {permission_set_id, permissions}) + permissions + end + end + + def invalidate(permission_set_id) do + :ets.delete(:permission_cache, permission_set_id) + end +end +``` + +**Benefits:** +- ✅ Runtime permission configuration +- ✅ More flexible than hardcoded +- ✅ Can add new permission sets without code changes + +**Trade-offs:** +- ⚠️ More complex (DB queries, cache, invalidation) +- ⚠️ Slightly slower (mitigated by cache) +- ⚠️ More testing needed + +**Estimated Effort:** 3-4 weeks + +**Decision Point:** Migrate to Phase 3 only if: +- Need to add permission sets frequently +- Need per-tenant permission customization +- MVP hardcoded approach is limiting business + +See [Migration Strategy](#migration-strategy) for detailed migration plan. + +--- + +## Migration Strategy + +### Three-Phase Approach + +**Phase 1: MVP (2-3 weeks) - CURRENT** +- Hardcoded PermissionSets module +- `HasPermission` check reads from module +- Role table with `permission_set_name` string +- Zero DB queries for permission checks + +**Phase 2: Field-Level (2-3 weeks) - FUTURE** +- Extend PermissionSets with `:fields` key +- Ash Calculations for read filtering +- Custom Validations for write protection +- No database schema changes + +**Phase 3: Database-Backed (3-4 weeks) - FUTURE** +- New tables: `permission_sets`, `permission_set_resources`, `permission_set_pages` +- New `HasResourcePermission` check queries DB +- ETS cache for performance +- Runtime permission configuration + +### When to Migrate? + +**Stay with MVP if:** +- 4 permission sets are sufficient +- Permission changes are rare (quarterly or less) +- Code deployments for permission changes are acceptable +- Performance is critical (< 1μs checks) + +**Migrate to Phase 2 if:** +- Need field-level granularity +- Different roles need access to different fields +- Still OK with hardcoded permissions + +**Migrate to Phase 3 if:** +- Need frequent permission changes +- Need per-tenant customization +- Want non-technical users to configure permissions +- OK with slightly more complex system + +### Migration from MVP to Phase 3 + +**Step-by-step:** + +1. **Create DB Tables** (1 day) + - Run migrations for `permission_sets`, `permission_set_resources`, `permission_set_pages` + - Add indexes + +2. **Seed from PermissionSets Module** (1 day) + - Script that reads from `PermissionSets.get_permissions/1` + - Inserts into new tables + - Verify data integrity + +3. **Create HasResourcePermission Check** (2 days) + - New check that queries DB + - Same logic as `HasPermission` but different data source + - Comprehensive tests + +4. **Implement ETS Cache** (2 days) + - Cache module + - Cache invalidation on updates + - Performance tests + +5. **Update Policies** (3 days) + - Replace `HasPermission` with `HasResourcePermission` in all resources + - Test each resource thoroughly + +6. **Update UI Helpers** (1 day) + - Modify `MvWeb.Authorization` to query DB + - Use cache for performance + +7. **Update Page Plug** (1 day) + - Modify `CheckPagePermission` to query DB + - Use cache + +8. **Integration Testing** (3 days) + - Full user journey tests + - Performance testing + - Load testing + +9. **Deploy to Staging** (1 day) + - Feature flag approach + - Run both systems in parallel + - Compare results + +10. **Deploy to Production** (1 day) + - Gradual rollout + - Monitor performance + - Rollback plan ready + +11. **Cleanup** (1 day) + - Remove old `HasPermission` check + - Remove `PermissionSets` module + - Update documentation + +**Total:** ~3-4 weeks + +--- + +## Security Considerations + +### Threat Model + +**Threats Addressed:** + +1. **Unauthorized Data Access:** Policies prevent users from accessing data outside their permissions +2. **Privilege Escalation:** Role-based system prevents users from granting themselves higher privileges +3. **UI Tampering:** Backend policies enforce authorization even if UI is bypassed +4. **Session Hijacking:** Mitigation handled by existing authentication system (not in scope) + +**Threats NOT Addressed:** + +1. **SQL Injection:** Ash Framework handles query building securely +2. **XSS:** Phoenix LiveView handles HTML escaping +3. **CSRF:** Phoenix CSRF tokens (existing) + +### Defense in Depth + +**Three Layers of Authorization:** + +1. **Page Access Layer (Plug):** + - Blocks unauthorized page access + - Runs before LiveView mounts + - Fast fail for obvious violations + +2. **UI Layer (Authorization Helpers):** + - Hides buttons/links user can't use + - Prevents confusing "forbidden" errors + - Improves UX + +3. **Resource Layer (Ash Policies):** + - **Primary enforcement point** + - Cannot be bypassed + - Filters queries automatically + +**Even if attacker:** +- Tampers with UI → Backend policies still enforce +- Calls API directly → Policies apply +- Modifies page JavaScript → Policies apply + +### Authorization Best Practices + +**DO:** +- ✅ Always preload `:role` relationship for actor +- ✅ Log authorization failures for debugging +- ✅ Use explicit policies (no implicit allow) +- ✅ Test policies with all role types +- ✅ Test special cases (nil role, invalid permission_set_name) + +**DON'T:** +- ❌ Trust UI-level checks alone +- ❌ Skip policy checks for "admin" +- ❌ Use `bypass` or `skip_authorization` in production +- ❌ Expose raw permission logic in API responses + +### Audit Logging (Future) + +**Not in MVP, but planned:** + +```elixir +defmodule Mv.Authorization.AuditLog do + def log_authorization_failure(actor, resource, action, reason) do + Ash.create!(AuditLog, %{ + user_id: actor.id, + resource: inspect(resource), + action: action, + outcome: "forbidden", + reason: reason, + ip_address: get_ip_address(), + timestamp: DateTime.utc_now() + }) + end +end +``` + +**Benefits:** +- Track suspicious authorization attempts +- Compliance (GDPR requires access logs) +- Debugging production issues + +--- + +## Appendix + +### Glossary + +- **Permission Set:** Named collection of permissions (e.g., "admin", "read_only") +- **Role:** Database entity linking users to permission sets +- **Scope:** Range of records permission applies to (:own, :linked, :all) +- **Actor:** Currently authenticated user in Ash context +- **Policy:** Ash authorization rule on a resource +- **System Role:** Role that cannot be deleted (is_system_role=true) +- **Special Case:** Authorization rule that takes precedence over general permissions + +### Resource Name Mapping + +The `HasPermission` check extracts resource names via `Module.split() |> List.last()`: + +| Ash Module | Resource Name (String) | +|------------|------------------------| +| `Mv.Accounts.User` | "User" | +| `Mv.Membership.Member` | "Member" | +| `Mv.Membership.Property` | "Property" | +| `Mv.Membership.PropertyType` | "PropertyType" | +| `Mv.Authorization.Role` | "Role" | + +These strings must match exactly in `PermissionSets` module. + +### Permission Set Summary + +| Permission Set | Typical Roles | Key Characteristics | +|----------------|---------------|---------------------| +| **own_data** | Mitglied | Can only access own data and linked member | +| **read_only** | Vorstand, Buchhaltung | Read all data, no modifications | +| **normal_user** | Kassenwart | Create/Read/Update members (no delete), full CRUD on properties, no admin | +| **admin** | Admin | Unrestricted access, wildcard pages | + +### Edge Case Reference + +| Edge Case | Behavior | Implementation | +|-----------|----------|----------------| +| User without role | Access denied everywhere | Seeds assign default role, runtime checks handle gracefully | +| Invalid permission_set_name | Access denied | Validation on Role, runtime safety checks | +| System role deletion | Forbidden | Validation prevents deletion if `is_system_role=true` | +| Linked member email | Admin-only edit | Custom validation in Member resource | +| Own credentials | Always accessible | Special policy before general check | + +### Testing Checklist + +**For Each Resource:** +- [ ] All 5 roles tested (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin) +- [ ] All actions tested (read, create, update, destroy) +- [ ] All scopes tested (own, linked, all) +- [ ] Special cases tested +- [ ] Edge cases tested (nil role, invalid permission_set_name) + +**For UI:** +- [ ] Buttons/links show/hide correctly per role +- [ ] Page access controlled per role +- [ ] No broken links (all visible links are accessible) + +**Integration:** +- [ ] One complete user journey per role +- [ ] Cross-resource scenarios (e.g., Member -> Property) +- [ ] Special cases in context (e.g., linked member email during full edit flow) + +### Useful Commands + +```bash +# Run all authorization tests +mix test test/mv/authorization + +# Run integration tests +mix test test/integration + +# Run with coverage +mix test --cover + +# Generate migrations +mix ash.codegen + +# Run seeds +mix run priv/repo/seeds/authorization_seeds.exs + +# Check permission for user in IEx +iex> user = Mv.Accounts.get_user!("user-id") +iex> MvWeb.Authorization.can?(user, :create, Mv.Membership.Member) + +# Check page access in IEx +iex> MvWeb.Authorization.can_access_page?(user, "/members/new") +``` + +--- + +**Document Version:** 2.0 (Clean Rewrite) +**Last Updated:** 2025-01-13 +**Status:** Ready for Implementation + +**Changes from V1:** +- Complete rewrite focused on MVP (hardcoded permissions) +- Removed all database-backed permission details from MVP sections +- Unified naming (HasPermission for MVP) +- Added Role resource policies +- Clarified resource-specific :linked scope +- Moved Phase 2 and Phase 3 to clearly marked "Future" sections +- Fixed Buchhaltung inconsistency (read_only everywhere) +- Added comprehensive security section +- Enhanced edge case documentation + +--- + +**End of Architecture Document** + diff --git a/docs/roles-and-permissions-implementation-plan.md b/docs/roles-and-permissions-implementation-plan.md new file mode 100644 index 0000000..0b173fa --- /dev/null +++ b/docs/roles-and-permissions-implementation-plan.md @@ -0,0 +1,1653 @@ +# Roles and Permissions - Implementation Plan (MVP) + +**Version:** 2.0 (Clean Rewrite) +**Date:** 2025-01-13 +**Status:** Ready for Implementation +**Related Documents:** +- [Overview](./roles-and-permissions-overview.md) - High-level concepts +- [Architecture](./roles-and-permissions-architecture.md) - Technical specification + +--- + +## Table of Contents + +- [Executive Summary](#executive-summary) +- [MVP Scope](#mvp-scope) +- [Implementation Strategy](#implementation-strategy) +- [Issue Breakdown](#issue-breakdown) + - [Sprint 1: Foundation](#sprint-1-foundation-week-1) + - [Sprint 2: Policies](#sprint-2-policies-week-2) + - [Sprint 3: Special Cases & Seeds](#sprint-3-special-cases--seeds-week-3) + - [Sprint 4: UI & Integration](#sprint-4-ui--integration-week-4) +- [Dependencies & Parallelization](#dependencies--parallelization) +- [Testing Strategy](#testing-strategy) +- [Migration & Rollback](#migration--rollback) +- [Risk Management](#risk-management) + +--- + +## Executive Summary + +### Overview + +This document defines the implementation plan for the **MVP (Phase 1)** of the Roles and Permissions system using **hardcoded Permission Sets** in an Elixir module. + +**Key Characteristics:** +- **15 issues total** (Issues #1-3, #6-17) +- **2-3 weeks duration** +- **180+ tests** +- **Test-Driven Development (TDD)** throughout +- **No database tables for permissions** - only `roles` table +- **Zero performance concerns** - all permission checks are in-memory function calls + +### What's NOT in MVP + +**Deferred to Phase 3 (Future):** +- Issue #4: `PermissionSetResource` database table +- Issue #5: `PermissionSetPage` database table +- Issue #18: ETS Permission Cache +- Database-backed dynamic permissions + +### The Four Permission Sets + +Hardcoded in `Mv.Authorization.PermissionSets` module: + +1. **own_data** - User can only access their own data (default for "Mitglied") +2. **read_only** - Read access to all members/properties (for "Vorstand", "Buchhaltung") +3. **normal_user** - Create/Read/Update members (no delete), full CRUD on properties (for "Kassenwart") +4. **admin** - Unrestricted access including user/role management (for "Admin") + +### The Five Roles + +Stored in database `roles` table, each referencing a `permission_set_name`: + +1. **Mitglied** → "own_data" (is_system_role=true, default) +2. **Vorstand** → "read_only" +3. **Kassenwart** → "normal_user" +4. **Buchhaltung** → "read_only" +5. **Admin** → "admin" + +--- + +## MVP Scope + +### What We're Building + +**Core Authorization System:** +- ✅ Hardcoded PermissionSets module with 4 permission sets +- ✅ Role database table and CRUD interface +- ✅ Custom Ash Policy Check (`HasPermission`) that reads from PermissionSets +- ✅ Policies on all resources (Member, User, Property, PropertyType, Role) +- ✅ Page-level permissions via Phoenix Plug +- ✅ UI authorization helpers for conditional rendering +- ✅ Special case: Member email validation for linked users +- ✅ Seed data for 5 roles + +**Benefits of Hardcoded Approach:** +- **Speed:** 2-3 weeks vs. 4-5 weeks for DB-backed +- **Performance:** < 1 microsecond per check (pure function call) +- **Simplicity:** No cache, no DB queries, easy to reason about +- **Version Control:** All permission changes tracked in Git +- **Testing:** Deterministic, no DB setup needed + +**Clear Migration Path to Phase 3:** +- Architecture document defines exact DB schema for future +- HasPermission check can be swapped for DB-querying version +- Role->PermissionSet link remains unchanged + +--- + +## Implementation Strategy + +### Test-Driven Development + +**Every issue follows TDD:** +1. Write failing tests first +2. Implement minimum code to pass tests +3. Refactor if needed +4. All tests must pass before moving on + +**Test Types:** +- **Unit Tests:** Individual modules (PermissionSets, Policy checks, Helpers) +- **Integration Tests:** Cross-resource authorization, special cases +- **LiveView Tests:** UI rendering, page permissions +- **E2E Tests:** Complete user journeys (one per role) + +### Incremental Rollout + +**Feature Flag Approach:** +- Implement behind environment variable `ENABLE_RBAC` +- Default: `false` (existing auth remains active) +- Test thoroughly in staging +- Flip flag in production after validation +- Allows instant rollback if needed + +### Definition of Done (All Issues) + +- [ ] All acceptance criteria met +- [ ] All tests written and passing +- [ ] Code reviewed and approved +- [ ] Documentation updated +- [ ] No linter errors +- [ ] Manual testing completed +- [ ] Feature flag tested (on/off states) + +--- + +## Issue Breakdown + +### Sprint 1: Foundation (Week 1) + +#### Issue #1: Create Authorization Domain and Role Resource + +**Size:** M (2 days) +**Dependencies:** None +**Assignable to:** Backend Developer + +**Description:** + +Create the authorization domain in Ash with the `Role` resource. This establishes the foundation for all authorization logic. + +**Tasks:** + +1. Create `lib/mv/authorization/` directory +2. Create `lib/mv/authorization/role.ex` Ash resource with: + - `id` (UUIDv7, primary key) + - `name` (String, unique, required) - e.g., "Vorstand", "Admin" + - `description` (String, optional) + - `permission_set_name` (String, required) - must be one of: "own_data", "read_only", "normal_user", "admin" + - `is_system_role` (Boolean, default false) - prevents deletion + - timestamps +3. Add validation: `permission_set_name` must exist in `PermissionSets.all_permission_sets/0` +4. Add `role_id` (UUID, nullable, foreign key) to `users` table +5. Add `belongs_to :role` relationship in User resource +6. Run `mix ash.codegen` to generate migrations +7. Review and apply migrations + +**Acceptance Criteria:** + +- [ ] Role resource created with all fields +- [ ] Migration applied successfully +- [ ] User.role relationship works +- [ ] Validation prevents invalid `permission_set_name` +- [ ] `is_system_role` flag present + +**Test Strategy:** + +**Smoke Tests Only** (detailed behavior tests in later issues): + +- Role resource can be loaded via `Code.ensure_loaded?(Mv.Authorization.Role)` +- Migration created valid table (manually verify with `psql`) +- User resource can be loaded and has `:role` in `relationships()` + +**No extensive behavior tests** - those come in Issue #3 (Role CRUD). + +**Test File:** `test/mv/authorization/role_test.exs` (minimal smoke tests) + +--- + +#### Issue #2: PermissionSets Elixir Module (Hardcoded Permissions) + +**Size:** M (2 days) +**Dependencies:** None +**Can work in parallel:** Yes (parallel with #1) +**Assignable to:** Backend Developer + +**Description:** + +Create the core `PermissionSets` module that defines all four permission sets with their resource and page permissions. This is the heart of the MVP's authorization logic. + +**Tasks:** + +1. Create `lib/mv/authorization/permission_sets.ex` +2. Define module with `@moduledoc` explaining the 4 permission sets +3. Define types: + ```elixir + @type scope :: :own | :linked | :all + @type action :: :read | :create | :update | :destroy + @type resource_permission :: %{ + resource: String.t(), + action: action(), + scope: scope(), + granted: boolean() + } + @type permission_set :: %{ + resources: [resource_permission()], + pages: [String.t()] + } + ``` +4. Implement `get_permissions/1` for each of the 4 permission sets +5. Implement `all_permission_sets/0` returning `[:own_data, :read_only, :normal_user, :admin]` +6. Implement `valid_permission_set?/1` checking if name is in the list +7. Implement `permission_set_name_to_atom/1` with error handling +8. Add comprehensive `@doc` examples for each function + +**Permission Set Details:** + +**1. own_data (Mitglied):** +- Resources: + - User: read/update :own + - Member: read/update :linked + - Property: read/update :linked + - PropertyType: read :all +- Pages: `["/", "/profile", "/members/:id"]` + +**2. read_only (Vorstand, Buchhaltung):** +- Resources: + - User: read :own, update :own + - Member: read :all + - Property: read :all + - PropertyType: read :all +- Pages: `["/", "/members", "/members/:id", "/properties"]` + +**3. normal_user (Kassenwart):** +- Resources: + - User: read/update :own + - Member: read/create/update :all (no destroy for safety) + - Property: read/create/update/destroy :all + - PropertyType: read :all +- Pages: `["/", "/members", "/members/new", "/members/:id", "/members/:id/edit", "/properties", "/properties/new", "/properties/:id/edit"]` + +**4. admin:** +- Resources: + - User: read/update/destroy :all + - Member: read/create/update/destroy :all + - Property: read/create/update/destroy :all + - PropertyType: read/create/update/destroy :all + - Role: read/create/update/destroy :all +- Pages: `["*"]` (wildcard = all pages) + +**Acceptance Criteria:** + +- [ ] Module created with all 4 permission sets +- [ ] `get_permissions/1` returns correct structure for each set +- [ ] `valid_permission_set?/1` works for atoms and strings +- [ ] `permission_set_name_to_atom/1` handles errors gracefully +- [ ] All functions have `@doc` and `@spec` +- [ ] Code is readable and well-commented + +**Test Strategy (TDD):** + +**Structure Tests:** +- `get_permissions(:own_data)` returns map with `:resources` and `:pages` keys +- Each permission set returns list of resource permissions +- Each resource permission has required keys: `:resource`, `:action`, `:scope`, `:granted` +- Pages lists are non-empty (except potentially for restricted roles) + +**Permission Content Tests:** +- `:own_data` allows User read/update with scope :own +- `:own_data` allows Member/Property read/update with scope :linked +- `:read_only` allows Member/Property read with scope :all +- `:read_only` does NOT allow Member/Property create/update/destroy +- `:normal_user` allows Member/Property full CRUD with scope :all +- `:admin` allows everything with scope :all +- `:admin` has wildcard page permission "*" + +**Validation Tests:** +- `valid_permission_set?("own_data")` returns true +- `valid_permission_set?(:admin)` returns true +- `valid_permission_set?("invalid")` returns false +- `permission_set_name_to_atom("own_data")` returns `{:ok, :own_data}` +- `permission_set_name_to_atom("invalid")` returns `{:error, :invalid_permission_set}` + +**Edge Cases:** +- All 4 sets defined in `all_permission_sets/0` +- Function doesn't crash on nil input (returns false/error tuple) + +**Test File:** `test/mv/authorization/permission_sets_test.exs` + +--- + +#### Issue #3: Role CRUD LiveViews + +**Size:** M (3 days) +**Dependencies:** #1 (Role resource) +**Assignable to:** Backend Developer + Frontend Developer + +**Description:** + +Create LiveView interface for administrators to manage roles. Only admins should be able to access this. + +**Tasks:** + +1. Create `lib/mv_web/live/role_live/` directory +2. Implement `index.ex` - List all roles +3. Implement `show.ex` - View role details +4. Implement `form.ex` - Create/Edit role form component +5. Add routes in `router.ex` under `/admin` scope +6. Create table component showing: name, description, permission_set_name, is_system_role +7. Add form validation for `permission_set_name` (dropdown with 4 options) +8. Prevent deletion of system roles (UI + backend) +9. Add flash messages for success/error +10. Style with existing DaisyUI theme + +**Acceptance Criteria:** + +- [ ] Index page lists all roles +- [ ] Show page displays role details +- [ ] Form allows creating new roles +- [ ] Form allows editing non-system roles +- [ ] `permission_set_name` is dropdown (not free text) +- [ ] Cannot delete system roles (grayed out button + backend check) +- [ ] All CRUD operations work +- [ ] Routes are under `/admin/roles` + +**Test Strategy (TDD):** + +**LiveView Mount Tests:** +- Index page mounts successfully +- Index page loads all roles from database +- Show page mounts with valid role ID +- Show page returns 404 for invalid role ID + +**CRUD Operation Tests:** +- Create new role with valid data succeeds +- Create new role with invalid `permission_set_name` shows error +- Update role name succeeds +- Update system role's `permission_set_name` succeeds +- Delete non-system role succeeds +- Delete system role fails with error message + +**UI Rendering Tests:** +- Index page shows table with role names +- System roles have badge/indicator +- Delete button disabled for system roles +- Form dropdown shows all 4 permission sets +- Flash messages appear after actions + +**Test File:** `test/mv_web/live/role_live_test.exs` + +--- + +### Sprint 2: Policies (Week 2) + +#### Issue #6: Custom Policy Check - HasPermission + +**Size:** L (3-4 days) +**Dependencies:** #2 (PermissionSets), #3 (Role resource exists) +**Assignable to:** Senior Backend Developer + +**Description:** + +Create the core custom Ash Policy Check that reads permissions from the `PermissionSets` module and applies them to Ash queries. This is the bridge between hardcoded permissions and Ash's authorization system. + +**Tasks:** + +1. Create `lib/mv/authorization/checks/has_permission.ex` +2. Implement `use Ash.Policy.Check` +3. Implement `describe/1` - returns human-readable description +4. Implement `match?/3` - the core authorization logic: + - Extract `actor.role.permission_set_name` + - Convert to atom via `PermissionSets.permission_set_name_to_atom/1` + - Call `PermissionSets.get_permissions/1` + - Find matching permission for current resource + action + - Apply scope filter +5. Implement `apply_scope/3` helper: + - `:all` → `:authorized` (no filter) + - `:own` → `{:filter, expr(id == ^actor.id)}` + - `:linked` → resource-specific logic: + - Member: `{:filter, expr(user_id == ^actor.id)}` + - Property: `{:filter, expr(member.user_id == ^actor.id)}` (traverse relationship!) +6. Handle errors gracefully: + - No actor → `{:error, :no_actor}` + - No role → `{:error, :no_role}` + - Invalid permission_set_name → `{:error, :invalid_permission_set}` + - No matching permission → `{:error, :no_permission}` +7. Add logging for authorization failures (debug level) +8. Add comprehensive `@doc` with examples + +**Acceptance Criteria:** + +- [ ] Check module implements `Ash.Policy.Check` behavior +- [ ] `match?/3` correctly evaluates permissions from PermissionSets +- [ ] Scope filters work correctly (:all, :own, :linked) +- [ ] `:linked` scope handles Member and Property differently +- [ ] Errors are handled gracefully (no crashes) +- [ ] Authorization failures are logged +- [ ] Module is well-documented + +**Test Strategy (TDD):** + +**Permission Lookup Tests:** +- Actor with :admin permission_set has permission for all resources/actions +- Actor with :read_only permission_set has read permission for Member +- Actor with :read_only permission_set does NOT have create permission for Member +- Actor with :own_data permission_set has update permission for User with scope :own + +**Scope Application Tests - :all:** +- Actor with scope :all can access any record +- Query returns all records in database + +**Scope Application Tests - :own:** +- Actor with scope :own can access record where record.id == actor.id +- Actor with scope :own cannot access record where record.id != actor.id +- Query filters to only actor's own record + +**Scope Application Tests - :linked:** +- Actor with scope :linked can access Member where member.user_id == actor.id +- Actor with scope :linked can access Property where property.member.user_id == actor.id (relationship traversal!) +- Actor with scope :linked cannot access unlinked member +- Query correctly filters based on user_id relationship + +**Error Handling Tests:** +- `match?` with nil actor returns `{:error, :no_actor}` +- `match?` with actor missing role returns `{:error, :no_role}` +- `match?` with invalid permission_set_name returns `{:error, :invalid_permission_set}` +- `match?` with no matching permission returns `{:error, :no_permission}` +- No crashes on edge cases + +**Logging Tests:** +- Authorization failure logs at debug level +- Log includes actor ID, resource, action, reason + +**Test Files:** +- `test/mv/authorization/checks/has_permission_test.exs` + +--- + +#### Issue #7: Member Resource Policies + +**Size:** M (2 days) +**Dependencies:** #6 (HasPermission check) +**Can work in parallel:** Yes (parallel with #8, #9, #10) +**Assignable to:** Backend Developer + +**Description:** + +Add authorization policies to the Member resource using the new `HasPermission` check. + +**Tasks:** + +1. Open `lib/mv/membership/member.ex` +2. Add `policies` block at top of resource (before actions) +3. Configure policy to `Mv.Authorization.Checks.HasPermission` +4. Add policy for each action: + - `:read` → check HasPermission for :read + - `:create` → check HasPermission for :create + - `:update` → check HasPermission for :update + - `:destroy` → check HasPermission for :destroy +5. Add special policy: Allow user to read/update their linked member (before general policy) + ```elixir + policy action_type(:read) do + authorize_if expr(user_id == ^actor(:id)) + end + ``` +6. Ensure policies load actor with `:role` relationship preloaded +7. Test policies with different actors + +**Policy Order (Critical!):** +1. Allow user to access their own linked member (most specific) +2. Check HasPermission (general authorization) +3. Default: Forbid + +**Acceptance Criteria:** + +- [ ] Policies block added to Member resource +- [ ] All CRUD actions protected by HasPermission +- [ ] Special case: User can always access linked member +- [ ] Policy order is correct (specific before general) +- [ ] Actor preloads :role relationship +- [ ] All policies tested + +**Test Strategy (TDD):** + +**Policy Tests for :own_data (Mitglied):** +- User can read their linked member (user_id matches) +- User can update their linked member +- User cannot read unlinked member (returns empty list or forbidden) +- User cannot create member +- Verify scope :linked works + +**Policy Tests for :read_only (Vorstand):** +- User can read all members (returns all records) +- User cannot create member (returns Forbidden) +- User cannot update any member (returns Forbidden) +- User cannot destroy any member (returns Forbidden) + +**Policy Tests for :normal_user (Kassenwart):** +- User can read all members +- User can create new member +- User can update any member +- User cannot destroy member (not in permission set) + +**Policy Tests for :admin:** +- User can perform all CRUD operations on any member +- No restrictions + +**Test File:** `test/mv/membership/member_policies_test.exs` + +--- + +#### Issue #8: User Resource Policies + +**Size:** M (2 days) +**Dependencies:** #6 (HasPermission check) +**Can work in parallel:** Yes (parallel with #7, #9, #10) +**Assignable to:** Backend Developer + +**Description:** + +Add authorization policies to the User resource. Special case: Users can always read/update their own credentials. + +**Tasks:** + +1. Open `lib/mv/accounts/user.ex` +2. Add `policies` block +3. Add special policy: Allow user to always access their own account (before general policy) + ```elixir + policy action_type([:read, :update]) do + authorize_if expr(id == ^actor(:id)) + end + ``` +4. Add general policy: Check HasPermission for all actions +5. Ensure :destroy is admin-only (via HasPermission) +6. Preload :role relationship for actor + +**Policy Order:** +1. Allow user to read/update own account (id == actor.id) +2. Check HasPermission (for admin operations) +3. Default: Forbid + +**Acceptance Criteria:** + +- [ ] User can always read/update own credentials +- [ ] Only admin can read/update other users +- [ ] Only admin can destroy users +- [ ] Policy order is correct +- [ ] Actor preloads :role relationship + +**Test Strategy (TDD):** + +**Own Data Tests (All Roles):** +- User with :own_data can read own user record +- User with :own_data can update own email/password +- User with :own_data cannot read other users +- User with :read_only can read own data +- User with :normal_user can read own data +- Verify special policy takes precedence + +**Admin Tests:** +- Admin can read all users +- Admin can update any user's credentials +- Admin can destroy users +- Admin has unrestricted access + +**Forbidden Tests:** +- Non-admin cannot read other users +- Non-admin cannot update other users +- Non-admin cannot destroy users + +**Test File:** `test/mv/accounts/user_policies_test.exs` + +--- + +#### Issue #9: Property Resource Policies + +**Size:** M (2 days) +**Dependencies:** #6 (HasPermission check) +**Can work in parallel:** Yes (parallel with #7, #8, #10) +**Assignable to:** Backend Developer + +**Description:** + +Add authorization policies to the Property resource. Properties are linked to members, which are linked to users. + +**Tasks:** + +1. Open `lib/mv/membership/property.ex` +2. Add `policies` block +3. Add special policy: Allow user to read/update properties of their linked member + ```elixir + policy action_type([:read, :update]) do + authorize_if expr(member.user_id == ^actor(:id)) + end + ``` +4. Add general policy: Check HasPermission +5. Ensure Property preloads :member relationship for scope checks +6. Preload :role relationship for actor + +**Policy Order:** +1. Allow user to read/update properties of linked member +2. Check HasPermission +3. Default: Forbid + +**Acceptance Criteria:** + +- [ ] User can access properties of their linked member +- [ ] Policy traverses Member -> User relationship correctly +- [ ] HasPermission check works for other scopes +- [ ] Actor preloads :role relationship + +**Test Strategy (TDD):** + +**Linked Properties Tests (:own_data):** +- User can read properties of their linked member +- User can update properties of their linked member +- User cannot read properties of unlinked members +- Verify relationship traversal works (property.member.user_id) + +**Read-Only Tests:** +- User with :read_only can read all properties +- User with :read_only cannot create/update properties + +**Normal User Tests:** +- User with :normal_user can CRUD properties + +**Admin Tests:** +- Admin can perform all operations + +**Test File:** `test/mv/membership/property_policies_test.exs` + +--- + +#### Issue #10: PropertyType Resource Policies + +**Size:** S (1 day) +**Dependencies:** #6 (HasPermission check) +**Can work in parallel:** Yes (parallel with #7, #8, #9) +**Assignable to:** Backend Developer + +**Description:** + +Add authorization policies to the PropertyType resource. PropertyTypes are admin-managed, but readable by all. + +**Tasks:** + +1. Open `lib/mv/membership/property_type.ex` +2. Add `policies` block +3. Add read policy: All authenticated users can read (scope :all) +4. Add write policies: Only admin can create/update/destroy +5. Use HasPermission check + +**Acceptance Criteria:** + +- [ ] All users can read property types +- [ ] Only admin can create/update/destroy property types +- [ ] Policies tested + +**Test Strategy (TDD):** + +**Read Access (All Roles):** +- User with :own_data can read all property types +- User with :read_only can read all property types +- User with :normal_user can read all property types +- User with :admin can read all property types + +**Write Access (Admin Only):** +- Non-admin cannot create property type (Forbidden) +- Non-admin cannot update property type (Forbidden) +- Non-admin cannot destroy property type (Forbidden) +- Admin can create property type +- Admin can update property type +- Admin can destroy property type + +**Test File:** `test/mv/membership/property_type_policies_test.exs` + +--- + +#### Issue #11: Page Permission Router Plug + +**Size:** S (1 day) +**Dependencies:** #2 (PermissionSets), #6 (HasPermission) +**Can work in parallel:** Yes (after #2 and #6) +**Assignable to:** Backend Developer + +**Description:** + +Create a Phoenix plug that checks if the current user has permission to access the requested page/route. This runs before LiveView mounts. + +**Tasks:** + +1. Create `lib/mv_web/plugs/check_page_permission.ex` +2. Implement `init/1` and `call/2` +3. Extract page path from `conn.private[:phoenix_route]` (route template like "/members/:id") +4. Get user from `conn.assigns[:current_user]` +5. Get user's role and permission_set_name +6. Call `PermissionSets.get_permissions/1` to get allowed pages list +7. Match requested path against allowed patterns: + - Exact match: "/members" == "/members" + - Dynamic match: "/members/:id" matches "/members/123" + - Wildcard: "*" matches everything (admin) +8. If unauthorized: redirect to "/" with flash error "You don't have permission to access this page." +9. If authorized: continue (conn not halted) +10. Add plug to router pipelines (`:browser`, `:require_authenticated_user`) + +**Acceptance Criteria:** + +- [ ] Plug checks page permissions from PermissionSets +- [ ] Static routes work ("/members") +- [ ] Dynamic routes work ("/members/:id" matches "/members/123") +- [ ] Wildcard works for admin ("*") +- [ ] Unauthorized users redirected with flash message +- [ ] Plug added to appropriate router pipelines + +**Test Strategy (TDD):** + +**Static Route Tests:** +- User with permission for "/members" can access (conn not halted) +- User without permission for "/members" is denied (conn halted, redirected to "/") +- Flash error message present after denial + +**Dynamic Route Tests:** +- User with "/members/:id" permission can access "/members/123" +- User with "/members/:id/edit" permission can access "/members/456/edit" +- User with only "/members/:id" cannot access "/members/123/edit" +- Pattern matching works correctly + +**Wildcard Tests:** +- Admin with "*" permission can access any page +- Wildcard overrides all other checks + +**Unauthenticated User Tests:** +- Nil current_user is redirected to login +- Login redirect preserves attempted path (optional feature) + +**Error Handling Tests:** +- User with invalid permission_set_name is denied +- User with no role is denied +- Error is logged but user sees generic message + +**Test File:** `test/mv_web/plugs/check_page_permission_test.exs` + +--- + +### Sprint 3: Special Cases & Seeds (Week 3) + +#### Issue #12: Member Email Validation for Linked Members + +**Size:** M (2 days) +**Dependencies:** #7 (Member policies), #8 (User policies) +**Assignable to:** Backend Developer + +**Description:** + +Implement special validation: Only admins can edit a member's email if that member is linked to a user. This prevents breaking email synchronization. + +**Tasks:** + +1. Open `lib/mv/membership/member.ex` +2. Add custom validation in `validations` block: + ```elixir + validate changing(:email), on: :update do + validate &validate_email_change_permission/2 + end + ``` +3. Implement `validate_email_change_permission/2`: + - Check if member has `user_id` (is linked) + - If linked: Check if actor has User.update permission with scope :all (admin) + - If not admin: Return error "Only administrators can change email for members linked to users" + - If not linked: Allow change +4. Use `PermissionSets.get_permissions/1` to check admin status +5. Add tests for all cases + +**Acceptance Criteria:** + +- [ ] Non-admin can edit email of unlinked member +- [ ] Non-admin cannot edit email of linked member +- [ ] Admin can edit email of linked member +- [ ] Validation only runs when email changes +- [ ] Error message is clear and helpful + +**Test Strategy (TDD):** + +**Unlinked Member Tests:** +- User with :normal_user can update email of unlinked member +- User with :read_only cannot update email (caught by policy, not validation) +- Validation doesn't block if member.user_id is nil + +**Linked Member Tests:** +- User with :normal_user cannot update email of linked member (validation error) +- Error message mentions "administrators" and "linked to users" +- User with :admin can update email of linked member (validation passes) + +**No-Op Tests:** +- Validation doesn't run if email didn't change +- Updating other fields (name, address) works normally + +**Test File:** `test/mv/membership/member_email_validation_test.exs` + +--- + +#### Issue #13: Seed Data - Roles and Default Assignment + +**Size:** S (1 day) +**Dependencies:** #2 (PermissionSets), #3 (Role resource) +**Can work in parallel:** Yes (parallel with #12 after #2 and #3 complete) +**Assignable to:** Backend Developer + +**Description:** + +Create seed data for 5 roles and assign default "Mitglied" role to existing users. Optionally designate one admin via environment variable. + +**Tasks:** + +1. Create `priv/repo/seeds/authorization_seeds.exs` +2. Seed 5 roles using `Ash.Seed.seed!/2` or create actions: + - **Mitglied:** name="Mitglied", description="Default member role", permission_set_name="own_data", is_system_role=true + - **Vorstand:** name="Vorstand", description="Board member with read access", permission_set_name="read_only", is_system_role=false + - **Kassenwart:** name="Kassenwart", description="Treasurer with full member management", permission_set_name="normal_user", is_system_role=false + - **Buchhaltung:** name="Buchhaltung", description="Accounting with read access", permission_set_name="read_only", is_system_role=false + - **Admin:** name="Admin", description="Administrator with full access", permission_set_name="admin", is_system_role=false +3. Make idempotent: Use upsert logic (get by name, update if exists, create if not) +4. Assign "Mitglied" role to all users without role_id: + ```elixir + mitglied_role = Ash.get!(Role, name: "Mitglied") + users_without_role = Ash.read!(User, filter: expr(is_nil(role_id))) + Enum.each(users_without_role, fn user -> + Ash.update!(user, %{role_id: mitglied_role.id}) + end) + ``` +5. (Optional) Check for `ADMIN_EMAIL` env var, assign Admin role to that user +6. Add error handling with clear error messages +7. Add `IO.puts` statements to show progress + +**Acceptance Criteria:** + +- [ ] All 5 roles created with correct permission_set_name +- [ ] "Mitglied" has is_system_role=true +- [ ] Existing users without role get "Mitglied" role +- [ ] Optional: ADMIN_EMAIL user gets Admin role +- [ ] Seeds are idempotent (can run multiple times) +- [ ] Error messages are clear +- [ ] Progress is logged to console + +**Test Strategy (TDD):** + +**Role Creation Tests:** +- After running seeds, 5 roles exist +- Each role has correct permission_set_name: + - Mitglied → "own_data" + - Vorstand → "read_only" + - Kassenwart → "normal_user" + - Buchhaltung → "read_only" + - Admin → "admin" +- "Mitglied" role has is_system_role=true +- Other roles have is_system_role=false +- All permission_set_names are valid (exist in PermissionSets.all_permission_sets/0) + +**User Assignment Tests:** +- Users without role_id are assigned "Mitglied" role +- Users who already have role_id are not changed +- Count of users with "Mitglied" role increases by number of previously unassigned users + +**Idempotency Tests:** +- Running seeds twice doesn't create duplicate roles +- Each role name appears exactly once +- Running seeds twice doesn't reassign users who already have roles + +**Optional Admin Tests:** +- If ADMIN_EMAIL set, user with that email gets Admin role +- If ADMIN_EMAIL not set, no error occurs +- If email doesn't exist, error is logged but seeds continue + +**Error Handling Tests:** +- Seeds fail gracefully if invalid permission_set_name provided +- Error message indicates which permission_set_name is invalid + +**Test File:** `test/seeds/authorization_seeds_test.exs` + +--- + +### Sprint 4: UI & Integration (Week 4) + +#### Issue #14: UI Authorization Helper Module + +**Size:** M (2-3 days) +**Dependencies:** #2 (PermissionSets), #6 (HasPermission), #13 (Seeds - for testing) +**Assignable to:** Backend Developer + Frontend Developer + +**Description:** + +Create helper functions for UI-level authorization checks. These will be used in LiveView templates to conditionally render buttons, links, and sections based on user permissions. + +**Tasks:** + +1. Create `lib/mv_web/authorization.ex` +2. Implement `can?/3` for resource-level checks: + ```elixir + def can?(user, action, resource) when is_atom(resource) + # Returns true if user has permission for action on resource + # e.g., can?(current_user, :create, Mv.Membership.Member) + ``` +3. Implement `can?/3` for record-level checks: + ```elixir + def can?(user, action, %resource{} = record) + # Returns true if user has permission for action on specific record + # Applies scope checking (own, linked, all) + # e.g., can?(current_user, :update, member) + ``` +4. Implement `can_access_page?/2`: + ```elixir + def can_access_page?(user, page_path) + # Returns true if user's permission set includes page + # e.g., can_access_page?(current_user, "/members/new") + ``` +5. All functions use `PermissionSets.get_permissions/1` (same logic as HasPermission) +6. All functions handle nil user gracefully (return false) +7. Implement resource-specific scope checking (Member vs Property for :linked) +8. Add comprehensive `@doc` with template examples +9. Import helper in `mv_web.ex` `html_helpers` section + +**Acceptance Criteria:** + +- [ ] `can?/3` works for resource atoms +- [ ] `can?/3` works for record structs with scope checking +- [ ] `can_access_page?/2` matches page patterns correctly +- [ ] Nil user always returns false +- [ ] Invalid permission_set_name returns false (not crash) +- [ ] Helper imported in `mv_web.ex` +- [ ] Comprehensive documentation with examples + +**Test Strategy (TDD):** + +**can?/3 with Resource Atom:** +- Returns true when user has permission for resource+action +- Admin can create Member (returns true) +- Read-only cannot create Member (returns false) +- Nil user returns false + +**can?/3 with Record Struct - Scope :all:** +- Admin can update any member (returns true for any record) +- Normal user can update any member (scope :all) + +**can?/3 with Record Struct - Scope :own:** +- User can update own User record (record.id == user.id) +- User cannot update other User record (record.id != user.id) + +**can?/3 with Record Struct - Scope :linked:** +- User can update linked Member (member.user_id == user.id) +- User cannot update unlinked Member +- User can update Property of linked Member (property.member.user_id == user.id) +- User cannot update Property of unlinked Member +- Scope checking is resource-specific (Member vs Property) + +**can_access_page?/2:** +- User with page in list can access (returns true) +- User without page in list cannot access (returns false) +- Dynamic routes match correctly ("/members/:id" matches "/members/123") +- Admin wildcard "*" matches any page +- Nil user returns false + +**Error Handling:** +- User without role returns false +- User with invalid permission_set_name returns false (no crash) +- Handles missing fields gracefully + +**Test File:** `test/mv_web/authorization_test.exs` + +--- + +#### Issue #15: Admin UI for Role Management + +**Size:** M (2 days) +**Dependencies:** #14 (UI Authorization Helper) +**Assignable to:** Frontend Developer + +**Description:** + +Update Role management LiveViews to use authorization helpers for conditional rendering. Add UI polish. + +**Tasks:** + +1. Open `lib/mv_web/live/role_live/index.ex` +2. Add authorization checks for "New Role" button: + ```heex + <%= if can?(@current_user, :create, Mv.Authorization.Role) do %> + <.link patch={~p"/admin/roles/new"}>New Role + <% end %> + ``` +3. Add authorization checks for "Edit" and "Delete" buttons in table +4. Gray out/hide "Delete" for system roles +5. Update `show.ex` to hide edit button if user can't update +6. Add role badge/pill for system roles +7. Add permission_set_name badge with color coding: + - own_data → gray + - read_only → blue + - normal_user → green + - admin → red +8. Test UI with different user roles + +**Acceptance Criteria:** + +- [ ] Only admin sees "New Role" button +- [ ] Only admin sees "Edit" and "Delete" buttons +- [ ] System roles have visual indicator +- [ ] Delete button hidden/disabled for system roles +- [ ] Permission set badges are color-coded +- [ ] UI tested with all role types + +**Test Strategy (TDD):** + +**Admin View:** +- Admin sees "New Role" button +- Admin sees "Edit" buttons for all roles +- Admin sees "Delete" buttons for non-system roles +- Admin does not see "Delete" button for system roles + +**Non-Admin View:** +- Non-admin does not see "New Role" button (redirected by page permission plug anyway) +- Non-admin cannot access /admin/roles (caught by plug) + +**Visual Tests:** +- System roles have badge +- Permission set names are color-coded +- UI renders correctly + +**Test File:** `test/mv_web/live/role_live_authorization_test.exs` + +--- + +#### Issue #16: Apply UI Authorization to Existing LiveViews + +**Size:** L (3 days) +**Dependencies:** #14 (UI Authorization Helper) +**Can work in parallel:** Yes (parallel with #15) +**Assignable to:** Frontend Developer + +**Description:** + +Update all existing LiveViews (Member, User, Property, PropertyType) to use authorization helpers for conditional rendering. + +**Tasks:** + +1. **Member LiveViews:** + - Index: Hide "New Member" if can't create + - Index: Hide "Edit" and "Delete" buttons per record if can't update/destroy + - Show: Hide "Edit" button if can't update record + - Form: Should not be accessible (caught by page permission plug) + +2. **User LiveViews:** + - Index: Only show if user is admin + - Show: Only show other users if admin, always show own profile + - Edit: Only allow editing own profile or admin editing anyone + +3. **Property LiveViews:** + - Similar to Member (hide create/edit/delete based on permissions) + +4. **PropertyType LiveViews:** + - All users can view + - Only admin can create/edit/delete + +5. **Navbar:** + - Only show "Admin" dropdown if user has admin permission set + - Only show "Roles" link if can access /admin/roles + - Only show "Members" link if can access /members + - Always show "Profile" link + +6. Test all views with all 5 role types + +**Acceptance Criteria:** + +- [ ] All LiveViews use `can?/3` for conditional rendering +- [ ] Buttons/links hidden when user lacks permission +- [ ] Navbar shows appropriate links per role +- [ ] Tested with all 5 roles (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin) +- [ ] UI is clean (no awkward empty spaces from hidden buttons) + +**Test Strategy (TDD):** + +**Member Index - Mitglied (own_data):** +- Does not see "New Member" button +- Does not see list of members (empty or filtered) +- Can only see own linked member if navigated directly + +**Member Index - Vorstand (read_only):** +- Sees full member list +- Does not see "New Member" button +- Does not see "Edit" or "Delete" buttons + +**Member Index - Kassenwart (normal_user):** +- Sees full member list +- Sees "New Member" button +- Sees "Edit" button for all members +- Does not see "Delete" button (not in permission set) + +**Member Index - Admin:** +- Sees everything (New, Edit, Delete) + +**Navbar Tests (all roles):** +- Mitglied: Sees only "Home" and "Profile" +- Vorstand: Sees "Home", "Members" (read-only), "Profile" +- Kassenwart: Sees "Home", "Members", "Properties", "Profile" +- Buchhaltung: Sees "Home", "Members" (read-only), "Profile" +- Admin: Sees "Home", "Members", "Properties", "Property Types", "Admin", "Profile" + +**Test Files:** +- `test/mv_web/live/member_live_authorization_test.exs` +- `test/mv_web/live/user_live_authorization_test.exs` +- `test/mv_web/live/property_live_authorization_test.exs` +- `test/mv_web/live/property_type_live_authorization_test.exs` +- `test/mv_web/components/navbar_authorization_test.exs` + +--- + +#### Issue #17: Integration Tests - Complete User Journeys + +**Size:** L (3 days) +**Dependencies:** All above (full system must be functional) +**Assignable to:** Backend Developer + +**Description:** + +Write comprehensive integration tests that follow complete user journeys for each role. These tests verify that policies, UI helpers, and page permissions all work together correctly. + +**Tasks:** + +1. Create test file for each role: + - `test/integration/mitglied_journey_test.exs` + - `test/integration/vorstand_journey_test.exs` + - `test/integration/kassenwart_journey_test.exs` + - `test/integration/buchhaltung_journey_test.exs` + - `test/integration/admin_journey_test.exs` + +2. Each test follows a complete user flow: + - Login as user with role + - Navigate to allowed pages + - Attempt to access forbidden pages + - Perform allowed actions + - Attempt forbidden actions + - Verify UI shows/hides appropriate elements + +3. Test cross-cutting concerns: + - Email synchronization (Member <-> User) + - User-Member linking (admin only) + - System role protection + +**Acceptance Criteria:** + +- [ ] One integration test per role (5 total) +- [ ] Tests cover complete user journeys +- [ ] Tests verify both backend (policies) and frontend (UI helpers) +- [ ] Tests verify page permissions +- [ ] Tests verify special cases (email, linking, system roles) +- [ ] All tests pass + +**Test Strategy:** + +**Mitglied Journey:** +1. Login as Mitglied user +2. Can access home page and profile +3. Cannot access /members (redirected) +4. Cannot access /admin/roles (redirected) +5. Can view own linked member via direct URL +6. Can update own member data +7. Cannot update unlinked member +8. Can update own user credentials +9. Cannot view other users + +**Vorstand Journey:** +1. Login as Vorstand user +2. Can access /members (reads all members) +3. Cannot create member (no button in UI, backend forbids) +4. Cannot edit member (no button in UI, backend forbids) +5. Can access /members/:id (read-only view) +6. Cannot access /members/:id/edit (page permission denies) +7. Can update own credentials +8. Cannot access /admin/roles + +**Kassenwart Journey:** +1. Login as Kassenwart user +2. Can access /members +3. Can create new member +4. Can edit any member (except email if linked - see special case) +5. Cannot delete member +6. Can manage properties +7. Cannot manage property types (read-only) +8. Cannot access /admin/roles + +**Buchhaltung Journey:** +1. Login as Buchhaltung user +2. Can access /members (read-only) +3. Cannot create/edit members +4. Can view properties (read-only) +5. Same restrictions as Vorstand + +**Admin Journey:** +1. Login as Admin user +2. Can access all pages (wildcard permission) +3. Can CRUD all resources +4. Can edit member email even if linked +5. Can manage roles +6. Cannot delete system roles (backend prevents) +7. Can link/unlink users and members +8. Can edit any user's credentials + +**Special Cases Tests:** +- Member email editing (admin vs non-admin for linked member) +- System role deletion (always fails) +- User without role (access denied everywhere) +- User with invalid permission_set_name (access denied) + +**Test Files:** +- `test/integration/mitglied_journey_test.exs` +- `test/integration/vorstand_journey_test.exs` +- `test/integration/kassenwart_journey_test.exs` +- `test/integration/buchhaltung_journey_test.exs` +- `test/integration/admin_journey_test.exs` +- `test/integration/special_cases_test.exs` + +--- + +## Dependencies & Parallelization + +### Dependency Graph + +``` + ┌──────────────────┐ + │ Issue #1 │ + │ Auth Domain │ + │ + Role Res │ + └────────┬─────────┘ + │ + ┌────────────┴────────────┐ + │ │ + ┌───────▼────────┐ ┌───────▼────────┐ + │ Issue #2 │ │ Issue #3 │ + │ PermissionSets│ │ Role CRUD │ + │ Module │ │ LiveViews │ + └───────┬────────┘ └────────────────┘ + │ + │ + └────────────┬────────────┘ + │ + ┌────────▼─────────┐ + │ Issue #6 │ + │ HasPermission │ + │ Policy Check │ + └────────┬─────────┘ + │ + ┌────────────────────┼─────────────────────┐ + │ │ │ + ┌────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐ + │ Issue #7 │ │ Issue #8 │ │ Issue #11 │ + │ Member │ │ User │ │ Page Plug │ + │ Policies │ │ Policies │ └──────┬──────┘ + └────┬─────┘ └──────┬──────┘ │ + │ │ │ + ┌────▼─────┐ ┌──────▼──────┐ │ + │ Issue #9 │ │ Issue #10 │ │ + │ Property │ │ PropType │ │ + │ Policies │ │ Policies │ │ + └────┬─────┘ └──────┬──────┘ │ + │ │ │ + └────────────────────┴─────────────────────┘ + │ + ┌────────────┴────────────┐ + │ │ + ┌───────▼────────┐ ┌───────▼────────┐ + │ Issue #12 │ │ Issue #13 │ + │ Email Valid │ │ Seeds │ + └───────┬────────┘ └───────┬────────┘ + │ │ + └────────────┬────────────┘ + │ + ┌────────▼─────────┐ + │ Issue #14 │ + │ UI Helper │ + └────────┬─────────┘ + │ + ┌────────────┴────────────┐ + │ │ + ┌───────▼────────┐ ┌───────▼────────┐ + │ Issue #15 │ │ Issue #16 │ + │ Admin UI │ │ Apply UI Auth│ + └───────┬────────┘ └───────┬────────┘ + │ │ + └────────────┬────────────┘ + │ + ┌────────▼─────────┐ + │ Issue #17 │ + │ Integration │ + │ Tests │ + └──────────────────┘ +``` + +### Parallelization Opportunities + +**After Issue #1:** +- Issues #2 and #3 can run in parallel + +**After Issue #6:** +- Issues #7, #8, #9, #10, #11 can ALL run in parallel (5 issues!) +- This is the main parallelization opportunity + +**After Issues #7-#11:** +- Issues #12 and #13 can run in parallel + +**After Issue #14:** +- Issues #15 and #16 can run in parallel + +### Sprint Breakdown + +| Sprint | Issues | Duration | Can Parallelize | +|--------|--------|----------|-----------------| +| Sprint 1 | #1, #2, #3 | Week 1 | #2 and #3 after #1 | +| Sprint 2 | #6, #7, #8, #9, #10, #11 | Week 2 | #7-#11 after #6 (5 parallel!) | +| Sprint 3 | #12, #13 | Week 3 | Yes (2 parallel) | +| Sprint 4 | #14, #15, #16, #17 | Week 4 | #15 & #16 after #14 | + +--- + +## Testing Strategy + +### Test-Driven Development Process + +**For Every Issue:** +1. Read acceptance criteria +2. Write failing tests covering all criteria +3. Verify tests fail (red) +4. Implement minimum code to pass +5. Verify tests pass (green) +6. Refactor if needed +7. All tests still pass + +### Test Coverage Goals + +**Total Estimated Tests: 180+** + +| Test Type | Count | Coverage | +|-----------|-------|----------| +| Unit Tests | ~80 | PermissionSets module, Policy checks, Scope logic, UI helpers | +| Integration Tests | ~70 | Cross-resource authorization, Special cases, Email validation | +| LiveView Tests | ~25 | UI rendering, Page permissions, Conditional elements | +| E2E Journey Tests | ~5 | Complete user flows (one per role) | + +### What to Test (Focus on Behavior) + +**DO Test:** +- Permission lookups return correct results +- Policies allow/deny actions correctly +- Scope filters work (own, linked, all) +- UI elements show/hide based on permissions +- Page access is controlled +- Special cases work (email, system roles) +- Error handling (no crashes) + +**DON'T Test:** +- Database schema existence +- Table columns (Ash generates these) +- Implementation details +- Private functions (test through public API) + +### Test Files Structure + +``` +test/ +├── mv/ +│ └── authorization/ +│ ├── permission_sets_test.exs # Issue #2 +│ ├── role_test.exs # Issue #1 (smoke) +│ └── checks/ +│ └── has_permission_test.exs # Issue #6 +├── mv/accounts/ +│ └── user_policies_test.exs # Issue #8 +├── mv/membership/ +│ ├── member_policies_test.exs # Issue #7 +│ ├── member_email_validation_test.exs # Issue #12 +│ ├── property_policies_test.exs # Issue #9 +│ └── property_type_policies_test.exs # Issue #10 +├── mv_web/ +│ ├── authorization_test.exs # Issue #14 +│ ├── plugs/ +│ │ └── check_page_permission_test.exs # Issue #11 +│ └── live/ +│ ├── role_live_test.exs # Issue #3 +│ ├── role_live_authorization_test.exs # Issue #15 +│ ├── member_live_authorization_test.exs # Issue #16 +│ ├── user_live_authorization_test.exs # Issue #16 +│ ├── property_live_authorization_test.exs # Issue #16 +│ └── property_type_live_authorization_test.exs # Issue #16 +├── integration/ +│ ├── mitglied_journey_test.exs # Issue #17 +│ ├── vorstand_journey_test.exs # Issue #17 +│ ├── kassenwart_journey_test.exs # Issue #17 +│ ├── buchhaltung_journey_test.exs # Issue #17 +│ ├── admin_journey_test.exs # Issue #17 +│ └── special_cases_test.exs # Issue #17 +└── seeds/ + └── authorization_seeds_test.exs # Issue #13 +``` + +--- + +## Migration & Rollback + +### Database Migrations + +**Issue #1 creates one migration:** + +```elixir +# priv/repo/migrations/TIMESTAMP_add_authorization.exs +defmodule Mv.Repo.Migrations.AddAuthorization do + use Ecto.Migration + + def up do + # Create roles table + create table(:roles, primary_key: false) do + add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()") + add :name, :string, null: false + add :description, :text + add :permission_set_name, :string, null: false + add :is_system_role, :boolean, default: false, null: false + + timestamps() + end + + create unique_index(:roles, [:name]) + create index(:roles, [:permission_set_name]) + + # Add role_id to users table + alter table(:users) do + add :role_id, references(:roles, type: :binary_id, on_delete: :restrict) + end + + create index(:users, [:role_id]) + end + + def down do + drop index(:users, [:role_id]) + + alter table(:users) do + remove :role_id + end + + drop table(:roles) + end +end +``` + +### Data Migration (Seeds) + +**After migration applied:** + +Run seeds to create roles and assign defaults: + +```bash +mix run priv/repo/seeds/authorization_seeds.exs +``` + +### Rollback Plan + +**If issues discovered in production:** + +1. **Immediate Rollback:** + - Set `ENABLE_RBAC=false` environment variable + - Restart application + - Old authorization system takes over instantly + +2. **Database Rollback (if needed):** + ```bash + mix ecto.rollback --step 1 + ``` + - Removes `role_id` from users + - Removes `roles` table + - Existing auth untouched + +3. **Code Rollback:** + - Revert Git commit + - Redeploy previous version + +**Rollback Safety:** +- No existing tables modified (only additions) +- Feature flag allows instant disable +- Old auth code remains in place until RBAC proven stable + +--- + +## Risk Management + +### Identified Risks + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| **Policy order issues** | Medium | High | Clear documentation, strict order enforcement, integration tests verify policies work together | +| **Scope filter errors** | Medium | High | TDD approach, extensive scope tests (own/linked/all), test with all resource types | +| **UI/Policy divergence** | Low | Medium | UI helpers use same PermissionSets module as policies, shared logic, integration tests verify consistency | +| **Breaking existing auth** | Low | High | Feature flag allows instant rollback, parallel systems until proven, gradual rollout | +| **User without role edge case** | Low | Medium | Default "Mitglied" role assigned in seeds, validation on User.create, tests cover nil role | +| **Invalid permission_set_name** | Low | Low | Validation on Role resource, tests cover invalid names, error handling throughout | +| **Performance (not a concern)** | Very Low | Low | Hardcoded permissions are < 1 microsecond, no DB queries, no cache needed | + +### Edge Cases Handled + +**User without role:** +- Default: Access denied (no permissions) +- Seeds assign "Mitglied" to all existing users +- New users must be assigned role on creation + +**Invalid permission_set_name:** +- Role validation prevents creation +- Runtime checks handle gracefully (return false/error, no crash) +- Error logged for debugging + +**System role protection:** +- Cannot delete role with `is_system_role=true` +- UI hides delete button +- Backend validation prevents deletion +- "Mitglied" is system role by default + +**Linked member email:** +- Custom validation on Member resource +- Only admins can edit if member.user_id present +- Prevents breaking email synchronization + +**Missing actor context:** +- All policies check for actor presence +- Missing actor = access denied +- No crashes, graceful error handling + +### Performance Considerations + +**No concerns for MVP:** +- Hardcoded permissions are pure function calls +- No database queries for permission checks +- Pattern matching on small lists (< 50 items total) +- Typical check: < 1 microsecond +- Can handle 10,000+ requests/second easily + +**Future considerations (Phase 3):** +- If migrating to database-backed: add ETS cache +- Cache invalidation on role/permission changes +- Database indexes on permission tables + +--- + +## Success Criteria + +**MVP is successful when:** + +- [ ] All 15 issues completed +- [ ] All 180+ tests passing +- [ ] Zero linter errors +- [ ] Manual testing completed for all 5 roles +- [ ] Integration tests verify complete user journeys +- [ ] Feature flag tested (on/off states) +- [ ] Documentation complete +- [ ] Code review approved +- [ ] Deployed to staging and verified +- [ ] Performance verified (< 100ms per page load) +- [ ] No authorization bypasses found in security review + +**Ready for Production when:** + +- [ ] 1 week in staging with no critical issues +- [ ] All stakeholders have tested their role types +- [ ] Rollback plan tested +- [ ] Monitoring/alerting configured +- [ ] Runbook created for common issues + +--- + +## Next Steps After MVP + +**Phase 2: Field-Level Permissions (Future - 2-3 weeks)** + +- Extend PermissionSets with `:fields` key +- Implement Ash Calculations to filter readable fields +- Implement Custom Validations for writable fields +- No database changes needed +- See [Architecture Document](./roles-and-permissions-architecture.md) for details + +**Phase 3: Database-Backed Permissions (Future - 3-4 weeks)** + +- Create `permission_sets`, `permission_set_resources`, `permission_set_pages` tables +- Replace hardcoded PermissionSets module with DB queries +- Implement ETS cache for performance +- Allow runtime permission configuration +- See [Architecture Document](./roles-and-permissions-architecture.md) for migration strategy + +--- + +## Document History + +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0 | 2025-01-12 | AI Assistant | Initial version with DB-backed permissions | +| 2.0 | 2025-01-13 | AI Assistant | Complete rewrite for hardcoded MVP, removed all V1 references, fixed Buchhaltung inconsistency | + +--- + +## Appendix + +### Glossary + +- **Permission Set:** A named collection of resource and page permissions (e.g., "admin", "read_only") +- **Role:** A database entity that links users to a permission set +- **Scope:** The range of records a permission applies to (:own, :linked, :all) +- **Actor:** The currently authenticated user in Ash authorization context +- **System Role:** A role that cannot be deleted (is_system_role=true) + +### Key Files + +- `lib/mv/authorization/permission_sets.ex` - Core permissions logic +- `lib/mv/authorization/checks/has_permission.ex` - Ash policy check +- `lib/mv_web/authorization.ex` - UI helper functions +- `lib/mv_web/plugs/check_page_permission.ex` - Page access control +- `priv/repo/seeds/authorization_seeds.exs` - Role seed data + +### Useful Commands + +```bash +# Run all authorization tests +mix test test/mv/authorization + +# Run integration tests only +mix test test/integration + +# Run with coverage +mix test --cover + +# Generate migrations after Ash resource changes +mix ash.codegen + +# Run seeds +mix run priv/repo/seeds/authorization_seeds.exs + +# Check for linter errors +mix credo --strict +``` + +--- + +**End of Implementation Plan** + diff --git a/docs/roles-and-permissions-overview.md b/docs/roles-and-permissions-overview.md new file mode 100644 index 0000000..191e8b7 --- /dev/null +++ b/docs/roles-and-permissions-overview.md @@ -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. + diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 749740d..e7b614f 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -69,7 +69,7 @@ defmodule Mv.Accounts.User do # Default actions for framework/tooling integration: # - :read -> Standard read used across the app and by admin tooling. # - :destroy-> Standard delete used by admin tooling and maintenance tasks. - # + # # NOTE: :create is INTENTIONALLY excluded from defaults! # Using a default :create would bypass email-synchronization logic. # Always use one of these explicit create actions instead: diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index 90bbcaa..4c84c20 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -9,6 +9,7 @@ defmodule Mv.Membership.CustomField do ## 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 @@ -54,8 +55,14 @@ defmodule Mv.Membership.CustomField do end actions do - defaults [:create, :read, :update, :destroy] + defaults [:read, :update, :destroy] default_accept [:name, :value_type, :description, :immutable, :required] + + create :create do + accept [:name, :value_type, :description, :immutable, :required] + change Mv.Membership.CustomField.Changes.GenerateSlug + validate string_length(:slug, min: 1) + end end attributes do @@ -69,6 +76,15 @@ defmodule Mv.Membership.CustomField do 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, @@ -97,5 +113,6 @@ defmodule Mv.Membership.CustomField do identities do identity :unique_name, [:name] + identity :unique_slug, [:slug] end end diff --git a/lib/membership/custom_field/changes/generate_slug.ex b/lib/membership/custom_field/changes/generate_slug.ex new file mode 100644 index 0000000..061d7e7 --- /dev/null +++ b/lib/membership/custom_field/changes/generate_slug.ex @@ -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 diff --git a/lib/membership/member.ex b/lib/membership/member.ex index eeb12c9..8464388 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -152,7 +152,8 @@ defmodule Mv.Membership.Member do prepare fn query, _ctx -> q = Ash.Query.get_argument(query, :query) || "" - # 0.2 as similarity threshold (recommended) - lower value can lead to more results but also to more unspecific results + # 0.2 as similarity threshold (recommended) + # Lower value can lead to more results but also to more unspecific results threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2 if is_binary(q) and String.trim(q) != "" do @@ -187,8 +188,82 @@ defmodule Mv.Membership.Member do 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 + # + # Special behavior for email matching: + # - When user_email AND search_query are both provided: filter by email (email takes precedence) + # - When only user_email provided: return all unlinked members (caller should use filter_by_email_match helper) + # - When only search_query provided: filter by search terms + 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) + + # Start with base filter: only unlinked members + base_query = Ash.Query.filter(query, is_nil(user)) + + # Determine filtering strategy + # Priority: search_query (if present) > no filters + # user_email is used for POST-filtering via filter_by_email_match helper + if not is_nil(search_query) and String.trim(search_query) != "" do + # Search query present: Use fuzzy search (regardless of user_email) + trimmed = String.trim(search_query) + + # Use same fuzzy search as :search action (PostgreSQL Trigram + FTS) + base_query + |> Ash.Query.filter( + expr( + # Full-text search + # Trigram similarity for names + # Exact substring match for email + fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^trimmed) or + fragment("search_vector @@ plainto_tsquery('simple', ?)", ^trimmed) or + fragment("? % first_name", ^trimmed) or + fragment("? % last_name", ^trimmed) or + fragment("word_similarity(?, first_name) > 0.2", ^trimmed) or + fragment("word_similarity(?, last_name) > 0.2", ^trimmed) or + fragment("similarity(first_name, ?) > 0.2", ^trimmed) or + fragment("similarity(last_name, ?) > 0.2", ^trimmed) or + contains(email, ^trimmed) + ) + ) + |> Ash.Query.limit(10) + else + # No search query: return all unlinked members + # Caller should use filter_by_email_match helper for email match logic + base_query + |> Ash.Query.limit(10) + end + end + end end + # Public helper function to apply email match logic after query execution + # This should be called after using :available_for_linking with user_email argument + # + # If a member with matching email exists, returns only that member + # Otherwise returns all members (no filtering) + def filter_by_email_match(members, user_email) + when is_list(members) and is_binary(user_email) do + # Check if any member matches the email + email_match = Enum.find(members, &(&1.email == user_email)) + + if email_match do + # Return only the email-matched member + [email_match] + else + # No email match, return all members + members + end + end + + def filter_by_email_match(members, _user_email), do: members + validations do # Required fields are covered by allow_nil? false diff --git a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex index 9cea265..af68f96 100644 --- a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex +++ b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex @@ -41,18 +41,37 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do if should_validate? do case Ash.Changeset.fetch_change(changeset, :email) do {:ok, new_email} -> - check_email_uniqueness(new_email, member_id) + # 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) - check_email_uniqueness(current_email, member_id) + # 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 diff --git a/lib/mv_web/live/custom_field_live/form.ex b/lib/mv_web/live/custom_field_live/form.ex index b1d3f86..ab8f104 100644 --- a/lib/mv_web/live/custom_field_live/form.ex +++ b/lib/mv_web/live/custom_field_live/form.ex @@ -48,6 +48,7 @@ defmodule MvWeb.CustomFieldLive.Form do <.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" diff --git a/lib/mv_web/live/custom_field_live/index.ex b/lib/mv_web/live/custom_field_live/index.ex index 2870611..65a3ab3 100644 --- a/lib/mv_web/live/custom_field_live/index.ex +++ b/lib/mv_web/live/custom_field_live/index.ex @@ -43,8 +43,6 @@ defmodule MvWeb.CustomFieldLive.Index do rows={@streams.custom_fields} row_click={fn {_id, custom_field} -> JS.navigate(~p"/custom_fields/#{custom_field}") end} > - <:col :let={{_id, custom_field}} label="Id">{custom_field.id} - <:col :let={{_id, custom_field}} label="Name">{custom_field.name} <:col :let={{_id, custom_field}} label="Description">{custom_field.description} diff --git a/lib/mv_web/live/custom_field_live/show.ex b/lib/mv_web/live/custom_field_live/show.ex index 783cb4e..239b844 100644 --- a/lib/mv_web/live/custom_field_live/show.ex +++ b/lib/mv_web/live/custom_field_live/show.ex @@ -9,6 +9,8 @@ defmodule MvWeb.CustomFieldLive.Show do - 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 @@ -29,7 +31,7 @@ defmodule MvWeb.CustomFieldLive.Show do ~H""" <.header> - Custom field {@custom_field.id} + Custom field {@custom_field.slug} <:subtitle>This is a custom_field record from your database. <:actions> @@ -48,6 +50,13 @@ defmodule MvWeb.CustomFieldLive.Show do <.list> <:item title="Id">{@custom_field.id} + <:item title="Slug"> + {@custom_field.slug} +

+ {gettext("Auto-generated identifier (immutable)")} +

+ + <:item title="Name">{@custom_field.name} <:item title="Description">{@custom_field.description} diff --git a/lib/mv_web/live/custom_field_value_live/form.ex b/lib/mv_web/live/custom_field_value_live/form.ex index 7df4c69..4a7b02d 100644 --- a/lib/mv_web/live/custom_field_value_live/form.ex +++ b/lib/mv_web/live/custom_field_value_live/form.ex @@ -39,7 +39,7 @@ defmodule MvWeb.CustomFieldValueLive.Form do <.header> {@page_title} <:subtitle> - {gettext("Use this form to manage custom_field_value records in your database.")} + {gettext("Use this form to manage Custom Field Value records in your database.")} diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index cf7b687..82df862 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -120,6 +120,116 @@ defmodule MvWeb.UserLive.Form do <% end %> <% end %> + + +
+

{gettext("Linked Member")}

+ + <%= if @user && @user.member && !@unlink_member do %> + +
+
+
+

+ {@user.member.first_name} {@user.member.last_name} +

+

{@user.member.email}

+
+ +
+
+ <% else %> + <%= if @unlink_member do %> + +
+

+ {gettext("Unlinking scheduled")}: {gettext( + "Member will be unlinked when you save. Cannot select new member until saved." + )} +

+
+ <% end %> + +
+
+ + + <%= if length(@available_members) > 0 do %> +
+ <%= for member <- @available_members do %> +
+

{member.first_name} {member.last_name}

+

{member.email}

+
+ <% end %> +
+ <% end %> +
+ + <%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %> +
+

+ {gettext("Note")}: {gettext( + "A member with this email already exists. To link with a different member, please change one of the email addresses first." + )} +

+
+ <% end %> + + <%= if @selected_member_id && @selected_member_name do %> +
+

+ {gettext("Selected")}: {@selected_member_name} +

+

+ {gettext("Save to confirm linking.")} +

+
+ <% end %> +
+ <% end %> +
<.button phx-disable-with={gettext("Saving...")} variant="primary"> {gettext("Save User")} @@ -135,7 +245,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") @@ -147,6 +257,13 @@ 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) + |> load_initial_members() |> assign_form()} end @@ -170,22 +287,102 @@ defmodule MvWeb.UserLive.Form do 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}}) - socket = - socket - |> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully") - |> push_navigate(to: return_path(socket.assigns.return_to, user)) + # Unlink flag is set + socket.assigns[:unlink_member] -> + Mv.Accounts.update_user(user, %{member: nil}) - {:noreply, socket} + # 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, updated_user)) + + {:noreply, socket} + + {:error, error} -> + # Show error from member linking/unlinking + {:noreply, + put_flash(socket, :error, "Failed to update member relationship: #{inspect(error)}")} + 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)} + 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) + + {: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 + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do @@ -209,4 +406,53 @@ defmodule MvWeb.UserLive.Form do defp return_path("index", _user), do: ~p"/users" defp return_path("show", user), do: ~p"/users/#{user.id}" + + # Load initial members when the form is loaded or member is unlinked + 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 + + # Load members based on search query + 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 + + # Query available members using the Ash action + 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 end diff --git a/lib/mv_web/live/user_live/index.ex b/lib/mv_web/live/user_live/index.ex index 8803237..0c1d7be 100644 --- a/lib/mv_web/live/user_live/index.ex +++ b/lib/mv_web/live/user_live/index.ex @@ -25,7 +25,7 @@ defmodule MvWeb.UserLive.Index do @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, diff --git a/lib/mv_web/live/user_live/index.html.heex b/lib/mv_web/live/user_live/index.html.heex index 66e3b9e..3582046 100644 --- a/lib/mv_web/live/user_live/index.html.heex +++ b/lib/mv_web/live/user_live/index.html.heex @@ -50,6 +50,13 @@ {user.email} <:col :let={user} label={gettext("OIDC ID")}>{user.oidc_id} + <:col :let={user} label={gettext("Linked Member")}> + <%= if user.member do %> + {user.member.first_name} {user.member.last_name} + <% else %> + {gettext("No member linked")} + <% end %> + <:action :let={user}>
diff --git a/mix.exs b/mix.exs index b215d59..c6e4fb5 100644 --- a/mix.exs +++ b/mix.exs @@ -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 diff --git a/mix.lock b/mix.lock index 28683a3..77dcc09 100644 --- a/mix.lock +++ b/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"}, diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index f6acdca..b7f472d 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -16,7 +16,7 @@ msgid "Actions" msgstr "Aktionen" #: lib/mv_web/live/member_live/index.html.heex:200 -#: lib/mv_web/live/user_live/index.html.heex:65 +#: lib/mv_web/live/user_live/index.html.heex:72 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "Bist du sicher?" @@ -35,14 +35,14 @@ msgid "City" msgstr "Stadt" #: lib/mv_web/live/member_live/index.html.heex:202 -#: lib/mv_web/live/user_live/index.html.heex:67 +#: lib/mv_web/live/user_live/index.html.heex:74 #, elixir-autogen, elixir-format msgid "Delete" msgstr "Löschen" #: lib/mv_web/live/member_live/index.html.heex:194 -#: lib/mv_web/live/user_live/form.ex:141 -#: lib/mv_web/live/user_live/index.html.heex:59 +#: lib/mv_web/live/user_live/form.ex:251 +#: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format msgid "Edit" msgstr "Bearbeite" @@ -88,7 +88,7 @@ msgid "New Member" msgstr "Neues Mitglied" #: lib/mv_web/live/member_live/index.html.heex:191 -#: lib/mv_web/live/user_live/index.html.heex:56 +#: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" msgstr "Anzeigen" @@ -158,10 +158,10 @@ msgstr "Postleitzahl" msgid "Save Member" msgstr "Mitglied speichern" -#: lib/mv_web/live/custom_field_live/form.ex:63 +#: lib/mv_web/live/custom_field_live/form.ex:64 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 -#: lib/mv_web/live/user_live/form.ex:124 +#: lib/mv_web/live/user_live/form.ex:234 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "Speichern..." @@ -203,14 +203,14 @@ msgstr "Dies ist ein Mitglied aus deiner Datenbank." msgid "Yes" msgstr "Ja" -#: lib/mv_web/live/custom_field_live/form.ex:107 +#: lib/mv_web/live/custom_field_live/form.ex:108 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "erstellt" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_live/form.ex:109 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -252,10 +252,10 @@ msgstr "Ihre E-Mail-Adresse wurde bestätigt" msgid "Your password has successfully been reset" msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt" -#: lib/mv_web/live/custom_field_live/form.ex:66 +#: lib/mv_web/live/custom_field_live/form.ex:67 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 -#: lib/mv_web/live/user_live/form.ex:127 +#: lib/mv_web/live/user_live/form.ex:237 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "Abbrechen" @@ -265,7 +265,7 @@ msgstr "Abbrechen" msgid "Choose a member" msgstr "Mitglied auswählen" -#: lib/mv_web/live/custom_field_live/form.ex:59 +#: lib/mv_web/live/custom_field_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Description" msgstr "Beschreibung" @@ -285,7 +285,7 @@ msgstr "Aktiviert" msgid "ID" msgstr "ID" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "Unveränderlich" @@ -335,6 +335,7 @@ msgstr "Nicht gesetzt" #: lib/mv_web/live/user_live/form.ex:107 #: lib/mv_web/live/user_live/form.ex:115 +#: lib/mv_web/live/user_live/form.ex:210 #, elixir-autogen, elixir-format msgid "Note" msgstr "Hinweis" @@ -355,7 +356,7 @@ msgstr "Passwort-Authentifizierung" msgid "Profil" msgstr "Profil" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Required" msgstr "Erforderlich" @@ -375,7 +376,7 @@ msgstr "Mitglied auswählen" msgid "Settings" msgstr "Einstellungen" -#: lib/mv_web/live/user_live/form.ex:125 +#: lib/mv_web/live/user_live/form.ex:235 #, elixir-autogen, elixir-format msgid "Save User" msgstr "Benutzer*in speichern" @@ -400,7 +401,7 @@ msgstr "Nicht unterstützter Wertetyp: %{type}" msgid "Use this form to manage user records in your database." msgstr "Verwenden Sie dieses Formular, um Benutzer*innen-Datensätze zu verwalten." -#: lib/mv_web/live/user_live/form.ex:142 +#: lib/mv_web/live/user_live/form.ex:252 #: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" @@ -411,7 +412,7 @@ msgstr "Benutzer*in" msgid "Value" msgstr "Wert" -#: lib/mv_web/live/custom_field_live/form.ex:54 +#: lib/mv_web/live/custom_field_live/form.ex:55 #, elixir-autogen, elixir-format msgid "Value type" msgstr "Wertetyp" @@ -428,7 +429,7 @@ msgstr "aufsteigend" msgid "descending" msgstr "absteigend" -#: lib/mv_web/live/user_live/form.ex:141 +#: lib/mv_web/live/user_live/form.ex:251 #, elixir-autogen, elixir-format msgid "New" msgstr "Neue*r" @@ -503,6 +504,8 @@ msgstr "Passwort setzen" msgid "User will be created without a password. Check 'Set Password' to add one." msgstr "Benutzer*in wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen', um eines hinzuzufügen." +#: lib/mv_web/live/user_live/form.ex:126 +#: lib/mv_web/live/user_live/index.html.heex:53 #: lib/mv_web/live/user_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Linked Member" @@ -513,6 +516,7 @@ msgstr "Verknüpftes Mitglied" msgid "Linked User" msgstr "Verknüpfte*r Benutzer*in" +#: lib/mv_web/live/user_live/index.html.heex:57 #: lib/mv_web/live/user_live/show.ex:65 #, elixir-autogen, elixir-format msgid "No member linked" @@ -616,7 +620,7 @@ msgstr "Benutzerdefinierte Feldwerte" msgid "Custom field" msgstr "Benutzerdefiniertes Feld" -#: lib/mv_web/live/custom_field_live/form.ex:114 +#: lib/mv_web/live/custom_field_live/form.ex:115 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "Benutzerdefiniertes Feld erfolgreich %{action}" @@ -631,7 +635,7 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}" msgid "Please select a custom field first" msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/form.ex:65 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "Benutzerdefiniertes Feld speichern" @@ -646,12 +650,67 @@ msgstr "Benutzerdefinierten Feldwert speichern" msgid "Use this form to manage custom_field records in your database." msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten." -#: lib/mv_web/live/custom_field_value_live/form.ex:42 -#, elixir-autogen, elixir-format -msgid "Use this form to manage custom_field_value records in your database." -msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Datenbank zu verwalten." - #: lib/mv_web/components/layouts/navbar.ex:20 #, elixir-autogen, elixir-format msgid "Custom Fields" msgstr "Benutzerdefinierte Felder" + +#: lib/mv_web/live/custom_field_value_live/form.ex:42 +#, elixir-autogen, elixir-format, fuzzy +msgid "Use this form to manage Custom Field Value records in your database." +msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Datenbank zu verwalten." + +#: lib/mv_web/live/custom_field_live/show.ex:56 +#, elixir-autogen, elixir-format +msgid "Auto-generated identifier (immutable)" +msgstr "Automatisch generierte Kennung (unveränderlich)" + +#: lib/mv_web/live/user_live/form.ex:210 +#, elixir-autogen, elixir-format +msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." +msgstr "Ein Mitglied mit dieser E-Mail-Adresse existiert bereits. Um mit einem anderen Mitglied zu verknüpfen, ändern Sie bitte zuerst eine der E-Mail-Adressen." + +#: lib/mv_web/live/user_live/form.ex:185 +#, elixir-autogen, elixir-format +msgid "Available members" +msgstr "Verfügbare Mitglieder" + +#: lib/mv_web/live/user_live/form.ex:152 +#, elixir-autogen, elixir-format +msgid "Member will be unlinked when you save. Cannot select new member until saved." +msgstr "Mitglied wird beim Speichern entverknüpft. Neues Mitglied kann erst nach dem Speichern ausgewählt werden." + +#: lib/mv_web/live/user_live/form.ex:226 +#, elixir-autogen, elixir-format +msgid "Save to confirm linking." +msgstr "Speichern, um die Verknüpfung zu bestätigen." + +#: lib/mv_web/live/user_live/form.ex:169 +#, elixir-autogen, elixir-format +msgid "Search for a member to link..." +msgstr "Nach einem Mitglied zum Verknüpfen suchen..." + +#: lib/mv_web/live/user_live/form.ex:173 +#, elixir-autogen, elixir-format +msgid "Search for member to link" +msgstr "Nach Mitglied zum Verknüpfen suchen" + +#: lib/mv_web/live/user_live/form.ex:223 +#, elixir-autogen, elixir-format +msgid "Selected" +msgstr "Ausgewählt" + +#: lib/mv_web/live/user_live/form.ex:143 +#, elixir-autogen, elixir-format +msgid "Unlink Member" +msgstr "Mitglied entverknüpfen" + +#: lib/mv_web/live/user_live/form.ex:152 +#, elixir-autogen, elixir-format +msgid "Unlinking scheduled" +msgstr "Entverknüpfung geplant" + +#~ #: lib/mv_web/live/custom_field_live/form.ex:58 +#~ #, elixir-autogen, elixir-format +#~ msgid "Slug" +#~ msgstr "Slug" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index d150a60..75cb2b1 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -17,7 +17,7 @@ msgid "Actions" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:200 -#: lib/mv_web/live/user_live/index.html.heex:65 +#: lib/mv_web/live/user_live/index.html.heex:72 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "" @@ -36,14 +36,14 @@ msgid "City" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:202 -#: lib/mv_web/live/user_live/index.html.heex:67 +#: lib/mv_web/live/user_live/index.html.heex:74 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:194 -#: lib/mv_web/live/user_live/form.ex:141 -#: lib/mv_web/live/user_live/index.html.heex:59 +#: lib/mv_web/live/user_live/form.ex:251 +#: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format msgid "Edit" msgstr "" @@ -89,7 +89,7 @@ msgid "New Member" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:191 -#: lib/mv_web/live/user_live/index.html.heex:56 +#: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" msgstr "" @@ -159,10 +159,10 @@ msgstr "" msgid "Save Member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:63 +#: lib/mv_web/live/custom_field_live/form.ex:64 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 -#: lib/mv_web/live/user_live/form.ex:124 +#: lib/mv_web/live/user_live/form.ex:234 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "" @@ -204,14 +204,14 @@ msgstr "" msgid "Yes" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:107 +#: lib/mv_web/live/custom_field_live/form.ex:108 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_live/form.ex:109 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -253,10 +253,10 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:66 +#: lib/mv_web/live/custom_field_live/form.ex:67 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 -#: lib/mv_web/live/user_live/form.ex:127 +#: lib/mv_web/live/user_live/form.ex:237 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" @@ -266,7 +266,7 @@ msgstr "" msgid "Choose a member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:59 +#: lib/mv_web/live/custom_field_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Description" msgstr "" @@ -286,7 +286,7 @@ msgstr "" msgid "ID" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -336,6 +336,7 @@ msgstr "" #: lib/mv_web/live/user_live/form.ex:107 #: lib/mv_web/live/user_live/form.ex:115 +#: lib/mv_web/live/user_live/form.ex:210 #, elixir-autogen, elixir-format msgid "Note" msgstr "" @@ -356,7 +357,7 @@ msgstr "" msgid "Profil" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Required" msgstr "" @@ -376,7 +377,7 @@ msgstr "" msgid "Settings" msgstr "" -#: lib/mv_web/live/user_live/form.ex:125 +#: lib/mv_web/live/user_live/form.ex:235 #, elixir-autogen, elixir-format msgid "Save User" msgstr "" @@ -401,7 +402,7 @@ msgstr "" msgid "Use this form to manage user records in your database." msgstr "" -#: lib/mv_web/live/user_live/form.ex:142 +#: lib/mv_web/live/user_live/form.ex:252 #: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" @@ -412,7 +413,7 @@ msgstr "" msgid "Value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:54 +#: lib/mv_web/live/custom_field_live/form.ex:55 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" @@ -429,7 +430,7 @@ msgstr "" msgid "descending" msgstr "" -#: lib/mv_web/live/user_live/form.ex:141 +#: lib/mv_web/live/user_live/form.ex:251 #, elixir-autogen, elixir-format msgid "New" msgstr "" @@ -504,6 +505,8 @@ msgstr "" msgid "User will be created without a password. Check 'Set Password' to add one." msgstr "" +#: lib/mv_web/live/user_live/form.ex:126 +#: lib/mv_web/live/user_live/index.html.heex:53 #: lib/mv_web/live/user_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Linked Member" @@ -514,6 +517,7 @@ msgstr "" msgid "Linked User" msgstr "" +#: lib/mv_web/live/user_live/index.html.heex:57 #: lib/mv_web/live/user_live/show.ex:65 #, elixir-autogen, elixir-format msgid "No member linked" @@ -617,7 +621,7 @@ msgstr "" msgid "Custom field" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:114 +#: lib/mv_web/live/custom_field_live/form.ex:115 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "" @@ -632,7 +636,7 @@ msgstr "" msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/form.ex:65 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" @@ -647,12 +651,62 @@ msgstr "" msgid "Use this form to manage custom_field records in your database." msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex:42 -#, elixir-autogen, elixir-format -msgid "Use this form to manage custom_field_value records in your database." -msgstr "" - #: lib/mv_web/components/layouts/navbar.ex:20 #, elixir-autogen, elixir-format msgid "Custom Fields" msgstr "" + +#: lib/mv_web/live/custom_field_value_live/form.ex:42 +#, elixir-autogen, elixir-format +msgid "Use this form to manage Custom Field Value records in your database." +msgstr "" + +#: lib/mv_web/live/custom_field_live/show.ex:56 +#, elixir-autogen, elixir-format +msgid "Auto-generated identifier (immutable)" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:210 +#, elixir-autogen, elixir-format +msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:185 +#, elixir-autogen, elixir-format +msgid "Available members" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:152 +#, elixir-autogen, elixir-format +msgid "Member will be unlinked when you save. Cannot select new member until saved." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:226 +#, elixir-autogen, elixir-format +msgid "Save to confirm linking." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:169 +#, elixir-autogen, elixir-format +msgid "Search for a member to link..." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:173 +#, elixir-autogen, elixir-format +msgid "Search for member to link" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:223 +#, elixir-autogen, elixir-format +msgid "Selected" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:143 +#, elixir-autogen, elixir-format +msgid "Unlink Member" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:152 +#, elixir-autogen, elixir-format +msgid "Unlinking scheduled" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index df56e75..7cae329 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -17,7 +17,7 @@ msgid "Actions" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:200 -#: lib/mv_web/live/user_live/index.html.heex:65 +#: lib/mv_web/live/user_live/index.html.heex:72 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "" @@ -36,14 +36,14 @@ msgid "City" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:202 -#: lib/mv_web/live/user_live/index.html.heex:67 +#: lib/mv_web/live/user_live/index.html.heex:74 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:194 -#: lib/mv_web/live/user_live/form.ex:141 -#: lib/mv_web/live/user_live/index.html.heex:59 +#: lib/mv_web/live/user_live/form.ex:251 +#: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format msgid "Edit" msgstr "" @@ -89,7 +89,7 @@ msgid "New Member" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:191 -#: lib/mv_web/live/user_live/index.html.heex:56 +#: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" msgstr "" @@ -159,10 +159,10 @@ msgstr "" msgid "Save Member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:63 +#: lib/mv_web/live/custom_field_live/form.ex:64 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 -#: lib/mv_web/live/user_live/form.ex:124 +#: lib/mv_web/live/user_live/form.ex:234 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "" @@ -204,14 +204,14 @@ msgstr "" msgid "Yes" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:107 +#: lib/mv_web/live/custom_field_live/form.ex:108 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_live/form.ex:109 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -253,10 +253,10 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:66 +#: lib/mv_web/live/custom_field_live/form.ex:67 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 -#: lib/mv_web/live/user_live/form.ex:127 +#: lib/mv_web/live/user_live/form.ex:237 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" @@ -266,7 +266,7 @@ msgstr "" msgid "Choose a member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:59 +#: lib/mv_web/live/custom_field_live/form.ex:60 #, elixir-autogen, elixir-format msgid "Description" msgstr "" @@ -286,7 +286,7 @@ msgstr "" msgid "ID" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -336,6 +336,7 @@ msgstr "" #: lib/mv_web/live/user_live/form.ex:107 #: lib/mv_web/live/user_live/form.ex:115 +#: lib/mv_web/live/user_live/form.ex:210 #, elixir-autogen, elixir-format, fuzzy msgid "Note" msgstr "" @@ -356,7 +357,7 @@ msgstr "" msgid "Profil" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Required" msgstr "" @@ -376,7 +377,7 @@ msgstr "" msgid "Settings" msgstr "" -#: lib/mv_web/live/user_live/form.ex:125 +#: lib/mv_web/live/user_live/form.ex:235 #, elixir-autogen, elixir-format, fuzzy msgid "Save User" msgstr "" @@ -401,7 +402,7 @@ msgstr "" msgid "Use this form to manage user records in your database." msgstr "" -#: lib/mv_web/live/user_live/form.ex:142 +#: lib/mv_web/live/user_live/form.ex:252 #: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" @@ -412,7 +413,7 @@ msgstr "" msgid "Value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:54 +#: lib/mv_web/live/custom_field_live/form.ex:55 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" @@ -429,7 +430,7 @@ msgstr "" msgid "descending" msgstr "" -#: lib/mv_web/live/user_live/form.ex:141 +#: lib/mv_web/live/user_live/form.ex:251 #, elixir-autogen, elixir-format msgid "New" msgstr "" @@ -504,6 +505,8 @@ msgstr "Set Password" msgid "User will be created without a password. Check 'Set Password' to add one." msgstr "User will be created without a password. Check 'Set Password' to add one." +#: lib/mv_web/live/user_live/form.ex:126 +#: lib/mv_web/live/user_live/index.html.heex:53 #: lib/mv_web/live/user_live/show.ex:55 #, elixir-autogen, elixir-format, fuzzy msgid "Linked Member" @@ -514,6 +517,7 @@ msgstr "" msgid "Linked User" msgstr "" +#: lib/mv_web/live/user_live/index.html.heex:57 #: lib/mv_web/live/user_live/show.ex:65 #, elixir-autogen, elixir-format msgid "No member linked" @@ -617,7 +621,7 @@ msgstr "" msgid "Custom field" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:114 +#: lib/mv_web/live/custom_field_live/form.ex:115 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "" @@ -632,7 +636,7 @@ msgstr "" msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/form.ex:65 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" @@ -647,12 +651,67 @@ msgstr "" msgid "Use this form to manage custom_field records in your database." msgstr "" -#: lib/mv_web/live/custom_field_value_live/form.ex:42 -#, elixir-autogen, elixir-format, fuzzy -msgid "Use this form to manage custom_field_value records in your database." -msgstr "" - #: lib/mv_web/components/layouts/navbar.ex:20 #, elixir-autogen, elixir-format, fuzzy msgid "Custom Fields" msgstr "" + +#: lib/mv_web/live/custom_field_value_live/form.ex:42 +#, elixir-autogen, elixir-format, fuzzy +msgid "Use this form to manage Custom Field Value records in your database." +msgstr "" + +#: lib/mv_web/live/custom_field_live/show.ex:56 +#, elixir-autogen, elixir-format +msgid "Auto-generated identifier (immutable)" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:210 +#, elixir-autogen, elixir-format +msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:185 +#, elixir-autogen, elixir-format +msgid "Available members" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:152 +#, elixir-autogen, elixir-format +msgid "Member will be unlinked when you save. Cannot select new member until saved." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:226 +#, elixir-autogen, elixir-format +msgid "Save to confirm linking." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:169 +#, elixir-autogen, elixir-format +msgid "Search for a member to link..." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:173 +#, elixir-autogen, elixir-format +msgid "Search for member to link" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:223 +#, elixir-autogen, elixir-format, fuzzy +msgid "Selected" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:143 +#, elixir-autogen, elixir-format +msgid "Unlink Member" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:152 +#, elixir-autogen, elixir-format +msgid "Unlinking scheduled" +msgstr "" + +#~ #: lib/mv_web/live/custom_field_live/form.ex:58 +#~ #, elixir-autogen, elixir-format +#~ msgid "Slug" +#~ msgstr "" diff --git a/priv/repo/migrations/20251113180429_add_slug_to_custom_fields.exs b/priv/repo/migrations/20251113180429_add_slug_to_custom_fields.exs new file mode 100644 index 0000000..bebf799 --- /dev/null +++ b/priv/repo/migrations/20251113180429_add_slug_to_custom_fields.exs @@ -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 diff --git a/priv/resource_snapshots/repo/custom_fields/20251113180429.json b/priv/resource_snapshots/repo/custom_fields/20251113180429.json new file mode 100644 index 0000000..5a89de9 --- /dev/null +++ b/priv/resource_snapshots/repo/custom_fields/20251113180429.json @@ -0,0 +1,132 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "slug", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "value_type", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "immutable", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "required", + "type": "boolean" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "DB1D3D9F2F76F518CAEEA2CC855996CCD87FC4C8FDD3A37345CEF2980674D8F3", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "custom_fields_unique_name_index", + "keys": [ + { + "type": "atom", + "value": "name" + } + ], + "name": "unique_name", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "custom_fields_unique_slug_index", + "keys": [ + { + "type": "atom", + "value": "slug" + } + ], + "name": "unique_slug", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "custom_fields" +} \ No newline at end of file diff --git a/test/accounts/debug_changeset_test.exs b/test/accounts/debug_changeset_test.exs new file mode 100644 index 0000000..04a4df8 --- /dev/null +++ b/test/accounts/debug_changeset_test.exs @@ -0,0 +1,33 @@ +defmodule Mv.Accounts.DebugChangesetTest do + use Mv.DataCase, async: true + + alias Mv.Accounts + alias Mv.Membership + + test "debug: what's in the changeset when linking with same email" do + # Create member + {:ok, member} = + Membership.create_member(%{ + first_name: "Emma", + last_name: "Davis", + email: "emma@example.com" + }) + + IO.puts("\n=== MEMBER CREATED ===") + IO.puts("Member ID: #{member.id}") + IO.puts("Member Email: #{member.email}") + + # Try to create user with same email and link + IO.puts("\n=== ATTEMPTING TO CREATE USER WITH LINK ===") + + # Let's intercept the validation to see what's in the changeset + result = + Accounts.create_user(%{ + email: "emma@example.com", + member: %{id: member.id} + }) + + IO.puts("\n=== RESULT ===") + IO.inspect(result, label: "Result") + end +end diff --git a/test/accounts/user_member_linking_email_test.exs b/test/accounts/user_member_linking_email_test.exs new file mode 100644 index 0000000..5d72ac9 --- /dev/null +++ b/test/accounts/user_member_linking_email_test.exs @@ -0,0 +1,169 @@ +defmodule Mv.Accounts.UserMemberLinkingEmailTest do + @moduledoc """ + Tests email validation during user-member linking. + Implements rules from docs/email-sync.md. + Tests for Issue #168, specifically Problem #4: Email validation bug. + """ + + use Mv.DataCase, async: true + + alias Mv.Accounts + alias Mv.Membership + + describe "link with same email" do + test "succeeds when user.email == member.email" do + # Create member with specific email + {:ok, member} = + Membership.create_member(%{ + first_name: "Alice", + last_name: "Johnson", + email: "alice@example.com" + }) + + # Create user with same email and link to member + result = + Accounts.create_user(%{ + email: "alice@example.com", + member: %{id: member.id} + }) + + # Should succeed without errors + assert {:ok, user} = result + assert to_string(user.email) == "alice@example.com" + + # Reload to verify link + user = Ash.load!(user, [:member], domain: Mv.Accounts) + assert user.member.id == member.id + assert user.member.email == "alice@example.com" + end + + test "no validation error triggered when updating linked pair with same email" do + # Create member + {:ok, member} = + Membership.create_member(%{ + first_name: "Bob", + last_name: "Smith", + email: "bob@example.com" + }) + + # Create user and link + {:ok, user} = + Accounts.create_user(%{ + email: "bob@example.com", + member: %{id: member.id} + }) + + # Update user (should not trigger email validation error) + result = Accounts.update_user(user, %{email: "bob@example.com"}) + + assert {:ok, updated_user} = result + assert to_string(updated_user.email) == "bob@example.com" + end + end + + describe "link with different emails" do + test "fails if member.email is used by a DIFFERENT linked user" do + # Create first user and link to a different member + {:ok, other_member} = + Membership.create_member(%{ + first_name: "Other", + last_name: "Member", + email: "other@example.com" + }) + + {:ok, _user1} = + Accounts.create_user(%{ + email: "user1@example.com", + member: %{id: other_member.id} + }) + + # Reload to ensure email sync happened + _other_member = Ash.reload!(other_member) + + # Create a NEW member with different email + {:ok, member} = + Membership.create_member(%{ + first_name: "Charlie", + last_name: "Brown", + email: "charlie@example.com" + }) + + # Try to create user2 with email that matches the linked other_member + result = + Accounts.create_user(%{ + email: "user1@example.com", + member: %{id: member.id} + }) + + # Should fail because user1@example.com is already used by other_member (which is linked to user1) + assert {:error, _error} = result + end + + test "succeeds for unique emails" do + # Create member + {:ok, member} = + Membership.create_member(%{ + first_name: "David", + last_name: "Wilson", + email: "david@example.com" + }) + + # Create user with different but unique email + result = + Accounts.create_user(%{ + email: "user@example.com", + member: %{id: member.id} + }) + + # Should succeed + assert {:ok, user} = result + + # Email sync should update member's email to match user's + user = Ash.load!(user, [:member], domain: Mv.Accounts) + assert user.member.email == "user@example.com" + end + end + + describe "edge cases" do + test "unlinking and relinking with same email works (Problem #4)" do + # This is the exact scenario from Problem #4: + # 1. Link user and member (both have same email) + # 2. Unlink them (member keeps the email) + # 3. Try to relink (validation should NOT fail) + + # Create member + {:ok, member} = + Membership.create_member(%{ + first_name: "Emma", + last_name: "Davis", + email: "emma@example.com" + }) + + # Create user and link + {:ok, user} = + Accounts.create_user(%{ + email: "emma@example.com", + member: %{id: member.id} + }) + + # Verify they are linked + user = Ash.load!(user, [:member], domain: Mv.Accounts) + assert user.member.id == member.id + assert user.member.email == "emma@example.com" + + # Unlink + {:ok, unlinked_user} = Accounts.update_user(user, %{member: nil}) + assert is_nil(unlinked_user.member_id) + + # Member still has the email after unlink + member = Ash.reload!(member) + assert member.email == "emma@example.com" + + # Relink (should work - this is Problem #4) + result = Accounts.update_user(unlinked_user, %{member: %{id: member.id}}) + + assert {:ok, relinked_user} = result + assert relinked_user.member_id == member.id + end + end +end diff --git a/test/accounts/user_member_linking_test.exs b/test/accounts/user_member_linking_test.exs new file mode 100644 index 0000000..8072eaf --- /dev/null +++ b/test/accounts/user_member_linking_test.exs @@ -0,0 +1,130 @@ +defmodule Mv.Accounts.UserMemberLinkingTest do + @moduledoc """ + Integration tests for User-Member linking functionality. + + Tests the complete workflow of linking and unlinking members to users, + including email synchronization and validation rules. + """ + use Mv.DataCase, async: true + alias Mv.Accounts + alias Mv.Membership + + describe "User-Member Linking with Email Sync" do + test "link user to member with different email syncs member email" do + # Create user with one email + {:ok, user} = Accounts.create_user(%{email: "user@example.com"}) + + # Create member with different email + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "member@example.com" + }) + + # Link user to member + {:ok, updated_user} = Accounts.update_user(user, %{member: %{id: member.id}}) + + # Verify link exists + user_with_member = Ash.get!(Mv.Accounts.User, updated_user.id, load: [:member]) + assert user_with_member.member.id == member.id + + # Verify member email was synced to match user email + synced_member = Ash.get!(Mv.Membership.Member, member.id) + assert synced_member.email == "user@example.com" + end + + test "unlink member from user sets member to nil" do + # Create and link user and member + {:ok, user} = Accounts.create_user(%{email: "user@example.com"}) + + {:ok, member} = + Membership.create_member(%{ + first_name: "Jane", + last_name: "Smith", + email: "jane@example.com" + }) + + {:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member.id}}) + + # Verify link exists + user_with_member = Ash.get!(Mv.Accounts.User, linked_user.id, load: [:member]) + assert user_with_member.member.id == member.id + + # Unlink by setting member to nil + {:ok, unlinked_user} = Accounts.update_user(linked_user, %{member: nil}) + + # Verify link is removed + user_without_member = Ash.get!(Mv.Accounts.User, unlinked_user.id, load: [:member]) + assert is_nil(user_without_member.member) + + # Verify member still exists independently + member_still_exists = Ash.get!(Mv.Membership.Member, member.id) + assert member_still_exists.id == member.id + end + + test "cannot link member already linked to another user" do + # Create first user and link to member + {:ok, user1} = Accounts.create_user(%{email: "user1@example.com"}) + + {:ok, member} = + Membership.create_member(%{ + first_name: "Bob", + last_name: "Wilson", + email: "bob@example.com" + }) + + {:ok, _linked_user1} = Accounts.update_user(user1, %{member: %{id: member.id}}) + + # Create second user and try to link to same member + {:ok, user2} = Accounts.create_user(%{email: "user2@example.com"}) + + # Should fail because member is already linked + assert {:error, %Ash.Error.Invalid{}} = + Accounts.update_user(user2, %{member: %{id: member.id}}) + end + + test "cannot change member link directly, must unlink first" do + # Create user and link to first member + {:ok, user} = Accounts.create_user(%{email: "user@example.com"}) + + {:ok, member1} = + Membership.create_member(%{ + first_name: "Alice", + last_name: "Johnson", + email: "alice@example.com" + }) + + {:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member1.id}}) + + # Create second member + {:ok, member2} = + Membership.create_member(%{ + first_name: "Charlie", + last_name: "Brown", + email: "charlie@example.com" + }) + + # Try to directly change member link (should fail) + assert {:error, %Ash.Error.Invalid{errors: errors}} = + Accounts.update_user(linked_user, %{member: %{id: member2.id}}) + + # Verify error message mentions "Remove existing member first" + error_messages = Enum.map(errors, & &1.message) + assert Enum.any?(error_messages, &String.contains?(&1, "Remove existing member first")) + + # Two-step process: first unlink, then link new member + {:ok, unlinked_user} = Accounts.update_user(linked_user, %{member: nil}) + + # After unlinking, member1 still has the user's email + # Change member1's email to avoid conflict when relinking to member2 + {:ok, _} = Membership.update_member(member1, %{email: "alice_changed@example.com"}) + + {:ok, relinked_user} = Accounts.update_user(unlinked_user, %{member: %{id: member2.id}}) + + # Verify new link is established + user_with_new_member = Ash.get!(Mv.Accounts.User, relinked_user.id, load: [:member]) + assert user_with_new_member.member.id == member2.id + end + end +end diff --git a/test/membership/custom_field_slug_test.exs b/test/membership/custom_field_slug_test.exs new file mode 100644 index 0000000..ae6c42e --- /dev/null +++ b/test/membership/custom_field_slug_test.exs @@ -0,0 +1,397 @@ +defmodule Mv.Membership.CustomFieldSlugTest do + @moduledoc """ + Tests for automatic slug generation on CustomField resource. + + This test suite verifies: + 1. Slugs are automatically generated from the name attribute + 2. Slugs are unique (cannot have duplicates) + 3. Slugs are immutable (don't change when name changes) + 4. Slugs handle various edge cases (unicode, special chars, etc.) + 5. Slugs can be used for lookups + """ + use Mv.DataCase, async: true + + alias Mv.Membership.CustomField + + describe "automatic slug generation on create" do + test "generates slug from name with simple ASCII text" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Mobile Phone", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "mobile-phone" + end + + test "generates slug from name with German umlauts" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Café Müller", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "cafe-muller" + end + + test "generates slug with lowercase conversion" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "TEST NAME", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "test-name" + end + + test "generates slug by removing special characters" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "E-Mail & Address!", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "e-mail-address" + end + + test "generates slug by replacing multiple spaces with single hyphen" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Multiple Spaces", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "multiple-spaces" + end + + test "trims leading and trailing hyphens" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "-Test-", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "test" + end + + test "handles unicode characters properly (ß becomes ss)" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Straße", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "strasse" + end + end + + describe "slug uniqueness" do + test "prevents creating custom field with duplicate slug" do + # Create first custom field + {:ok, _custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test", + value_type: :string + }) + |> Ash.create() + + # Attempt to create second custom field with same slug (different case in name) + assert {:error, %Ash.Error.Invalid{} = error} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test", + value_type: :integer + }) + |> Ash.create() + + assert Exception.message(error) =~ "has already been taken" + end + + test "allows custom fields with different slugs" do + {:ok, custom_field1} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test One", + value_type: :string + }) + |> Ash.create() + + {:ok, custom_field2} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test Two", + value_type: :string + }) + |> Ash.create() + + assert custom_field1.slug == "test-one" + assert custom_field2.slug == "test-two" + assert custom_field1.slug != custom_field2.slug + end + + test "prevents duplicate slugs when names differ only in special characters" do + {:ok, custom_field1} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test!!!", + value_type: :string + }) + |> Ash.create() + + assert custom_field1.slug == "test" + + # Second custom field with name that generates the same slug should fail + assert {:error, %Ash.Error.Invalid{} = error} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test???", + value_type: :string + }) + |> Ash.create() + + # Should fail with uniqueness constraint error + assert Exception.message(error) =~ "has already been taken" + end + end + + describe "slug immutability" do + test "slug cannot be manually set on create" do + # Attempting to set slug manually should fail because slug is not writable + result = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test", + value_type: :string, + slug: "custom-slug" + }) + |> Ash.create() + + # Should fail because slug is not an accepted input + assert {:error, %Ash.Error.Invalid{}} = result + assert Exception.message(elem(result, 1)) =~ "No such input" + end + + test "slug does not change when name is updated" do + # Create custom field + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Original Name", + value_type: :string + }) + |> Ash.create() + + original_slug = custom_field.slug + assert original_slug == "original-name" + + # Update the name + {:ok, updated_custom_field} = + custom_field + |> Ash.Changeset.for_update(:update, %{ + name: "New Different Name" + }) + |> Ash.update() + + # Slug should remain unchanged + assert updated_custom_field.slug == original_slug + assert updated_custom_field.slug == "original-name" + assert updated_custom_field.name == "New Different Name" + end + + test "slug cannot be manually updated" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test", + value_type: :string + }) + |> Ash.create() + + original_slug = custom_field.slug + assert original_slug == "test" + + # Attempt to manually update slug should fail because slug is not writable + result = + custom_field + |> Ash.Changeset.for_update(:update, %{ + slug: "new-slug" + }) + |> Ash.update() + + # Should fail because slug is not an accepted input + assert {:error, %Ash.Error.Invalid{}} = result + assert Exception.message(elem(result, 1)) =~ "No such input" + + # Reload to verify slug hasn't changed + reloaded = Ash.get!(CustomField, custom_field.id) + assert reloaded.slug == "test" + end + end + + describe "slug edge cases" do + test "handles very long names by truncating slug" do + # Create a name at the maximum length (100 chars) + long_name = String.duplicate("abcdefghij", 10) + # 100 characters exactly + + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: long_name, + value_type: :string + }) + |> Ash.create() + + # Slug should be truncated to maximum 100 characters + assert String.length(custom_field.slug) <= 100 + # Should be the full slugified version since name is exactly 100 chars + assert custom_field.slug == long_name + end + + test "rejects name with only special characters" do + # When name contains only special characters, slug would be empty + # This should fail validation + assert {:error, %Ash.Error.Invalid{} = error} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "!!!", + value_type: :string + }) + |> Ash.create() + + # Should fail because slug would be empty + error_message = Exception.message(error) + assert error_message =~ "Slug cannot be empty" or error_message =~ "is required" + end + + test "handles mixed special characters and text" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test@#$%Name", + value_type: :string + }) + |> Ash.create() + + # slugify keeps the hyphen between words + assert custom_field.slug == "test-name" + end + + test "handles numbers in name" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Field 123 Test", + value_type: :string + }) + |> Ash.create() + + assert custom_field.slug == "field-123-test" + end + + test "handles consecutive hyphens in name" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test---Name", + value_type: :string + }) + |> Ash.create() + + # Should reduce multiple hyphens to single hyphen + assert custom_field.slug == "test-name" + end + + test "handles name with dots and underscores" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test.field_name", + value_type: :string + }) + |> Ash.create() + + # Dots and underscores should be handled (either kept or converted) + assert custom_field.slug =~ ~r/^[a-z0-9-]+$/ + end + end + + describe "slug in queries and responses" do + test "slug is included in struct after create" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test", + value_type: :string + }) + |> Ash.create() + + # Slug should be present in the struct + assert Map.has_key?(custom_field, :slug) + assert custom_field.slug != nil + end + + test "can load custom field and slug is present" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test", + value_type: :string + }) + |> Ash.create() + + # Load it back + loaded_custom_field = Ash.get!(CustomField, custom_field.id) + + assert loaded_custom_field.slug == "test" + end + + test "slug is returned in list queries" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test", + value_type: :string + }) + |> Ash.create() + + custom_fields = Ash.read!(CustomField) + + found = Enum.find(custom_fields, &(&1.id == custom_field.id)) + assert found.slug == "test" + end + end + + describe "slug-based lookup (future feature)" do + @tag :skip + test "can find custom field by slug" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "Test Field", + value_type: :string + }) + |> Ash.create() + + # This test is for future implementation + # We might add a custom action like :by_slug + found = Ash.get!(CustomField, custom_field.slug, load: [:slug]) + assert found.id == custom_field.id + end + end +end diff --git a/test/membership/member_available_for_linking_test.exs b/test/membership/member_available_for_linking_test.exs new file mode 100644 index 0000000..602fdfd --- /dev/null +++ b/test/membership/member_available_for_linking_test.exs @@ -0,0 +1,222 @@ +defmodule Mv.Membership.MemberAvailableForLinkingTest do + @moduledoc """ + Tests for the Member.available_for_linking action. + + This action returns members that can be linked to a user account: + - Only members without existing user links (user_id == nil) + - Limited to 10 results + - Special email-match logic: if user_email matches member email, only return that member + - Optional search query filtering by name and email + """ + use Mv.DataCase, async: true + alias Mv.Membership + + describe "available_for_linking/2" do + setup do + # Create 5 unlinked members with distinct names + {:ok, member1} = + Membership.create_member(%{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + + {:ok, member2} = + Membership.create_member(%{ + first_name: "Bob", + last_name: "Williams", + email: "bob@example.com" + }) + + {:ok, member3} = + Membership.create_member(%{ + first_name: "Charlie", + last_name: "Davis", + email: "charlie@example.com" + }) + + {:ok, member4} = + Membership.create_member(%{ + first_name: "Diana", + last_name: "Martinez", + email: "diana@example.com" + }) + + {:ok, member5} = + Membership.create_member(%{ + first_name: "Emma", + last_name: "Taylor", + email: "emma@example.com" + }) + + unlinked_members = [member1, member2, member3, member4, member5] + + # Create 2 linked members (with users) + {:ok, user1} = Mv.Accounts.create_user(%{email: "user1@example.com"}) + + {:ok, linked_member1} = + Membership.create_member(%{ + first_name: "Linked", + last_name: "Member1", + email: "linked1@example.com", + user: %{id: user1.id} + }) + + {:ok, user2} = Mv.Accounts.create_user(%{email: "user2@example.com"}) + + {:ok, linked_member2} = + Membership.create_member(%{ + first_name: "Linked", + last_name: "Member2", + email: "linked2@example.com", + user: %{id: user2.id} + }) + + %{ + unlinked_members: unlinked_members, + linked_members: [linked_member1, linked_member2] + } + end + + test "returns only unlinked members and limits to 10", %{ + unlinked_members: unlinked_members, + linked_members: _linked_members + } do + # Call the action without any arguments + members = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{}) + |> Ash.read!() + + # Should return only the 5 unlinked members, not the 2 linked ones + assert length(members) == 5 + + returned_ids = Enum.map(members, & &1.id) |> MapSet.new() + expected_ids = Enum.map(unlinked_members, & &1.id) |> MapSet.new() + + assert MapSet.equal?(returned_ids, expected_ids) + + # Verify none of the returned members have a user_id + Enum.each(members, fn member -> + member_with_user = Ash.get!(Mv.Membership.Member, member.id, load: [:user]) + assert is_nil(member_with_user.user) + end) + end + + test "limits results to 10 members even when more exist" do + # Create 15 additional unlinked members (total 20 unlinked) + for i <- 6..20 do + Membership.create_member(%{ + first_name: "Extra#{i}", + last_name: "Member#{i}", + email: "extra#{i}@example.com" + }) + end + + members = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{}) + |> Ash.read!() + + # Should be limited to 10 + assert length(members) == 10 + end + + test "email match: returns only member with matching email when exists", %{ + unlinked_members: unlinked_members + } do + # Get one of the unlinked members' email + target_member = List.first(unlinked_members) + user_email = target_member.email + + raw_members = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{user_email: user_email}) + |> Ash.read!() + + # Apply email match filtering (sorted results come from query) + # When user_email matches, only that member should be returned + members = Mv.Membership.Member.filter_by_email_match(raw_members, user_email) + + # Should return only the member with matching email + assert length(members) == 1 + assert List.first(members).id == target_member.id + assert List.first(members).email == user_email + end + + test "email match: returns all unlinked members when no email match" do + # Use an email that doesn't match any member + non_matching_email = "nonexistent@example.com" + + raw_members = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{user_email: non_matching_email}) + |> Ash.read!() + + # Apply email match filtering + members = Mv.Membership.Member.filter_by_email_match(raw_members, non_matching_email) + + # Should return all 5 unlinked members since no match + assert length(members) == 5 + end + + test "search query: filters by first_name, last_name, and email", %{ + unlinked_members: _unlinked_members + } do + # Search by first name + members = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{search_query: "Alice"}) + |> Ash.read!() + + assert length(members) == 1 + assert List.first(members).first_name == "Alice" + + # Search by last name + members = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{search_query: "Williams"}) + |> Ash.read!() + + assert length(members) == 1 + assert List.first(members).last_name == "Williams" + + # Search by email + members = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{search_query: "charlie@"}) + |> Ash.read!() + + assert length(members) == 1 + assert List.first(members).email == "charlie@example.com" + + # Search returns empty when no matches + members = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{search_query: "NonExistent"}) + |> Ash.read!() + + assert Enum.empty?(members) + end + + test "search query takes precedence over email match", %{unlinked_members: unlinked_members} do + target_member = List.first(unlinked_members) + + # Pass both email match and search query that would match different members + raw_members = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{ + user_email: target_member.email, + search_query: "Bob" + }) + |> Ash.read!() + + # Search query takes precedence, should match "Bob" in the first name + # user_email is used for POST-filtering only, not in the query + assert length(raw_members) == 1 + # Should find the member with "Bob" first name, not target_member (Alice) + assert List.first(raw_members).first_name == "Bob" + refute List.first(raw_members).id == target_member.id + end + end +end diff --git a/test/membership/member_fuzzy_search_linking_test.exs b/test/membership/member_fuzzy_search_linking_test.exs new file mode 100644 index 0000000..fcaf5fd --- /dev/null +++ b/test/membership/member_fuzzy_search_linking_test.exs @@ -0,0 +1,158 @@ +defmodule Mv.Membership.MemberFuzzySearchLinkingTest do + @moduledoc """ + Tests fuzzy search in Member.available_for_linking action. + Verifies PostgreSQL trigram matching for member search. + """ + + use Mv.DataCase, async: true + + alias Mv.Accounts + alias Mv.Membership + + describe "available_for_linking with fuzzy search" do + test "finds member despite typo" do + # Create member with specific name + {:ok, member} = + Membership.create_member(%{ + first_name: "Jonathan", + last_name: "Smith", + email: "jonathan@example.com" + }) + + # Search with typo + query = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{ + user_email: nil, + search_query: "Jonatan" + }) + + {:ok, members} = Ash.read(query, domain: Mv.Membership) + + # Should find Jonathan despite typo + assert length(members) == 1 + assert hd(members).id == member.id + end + + test "finds member with partial match" do + # Create member + {:ok, member} = + Membership.create_member(%{ + first_name: "Alexander", + last_name: "Williams", + email: "alex@example.com" + }) + + # Search with partial + query = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{ + user_email: nil, + search_query: "Alex" + }) + + {:ok, members} = Ash.read(query, domain: Mv.Membership) + + # Should find Alexander + assert length(members) == 1 + assert hd(members).id == member.id + end + + test "email match overrides fuzzy search" do + # Create two members + {:ok, member1} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "john@example.com" + }) + + {:ok, _member2} = + Membership.create_member(%{ + first_name: "Jane", + last_name: "Smith", + email: "jane@example.com" + }) + + # Search with user_email that matches member1, but search_query that would match member2 + query = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{ + user_email: "john@example.com", + search_query: "Jane" + }) + + {:ok, members} = Ash.read(query, domain: Mv.Membership) + + # Apply email filter + filtered_members = Mv.Membership.Member.filter_by_email_match(members, "john@example.com") + + # Should only return member1 (email match takes precedence) + assert length(filtered_members) == 1 + assert hd(filtered_members).id == member1.id + end + + test "limits to 10 results" do + # Create 15 members with similar names + for i <- 1..15 do + Membership.create_member(%{ + first_name: "Test#{i}", + last_name: "Member", + email: "test#{i}@example.com" + }) + end + + # Search for "Test" + query = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{ + user_email: nil, + search_query: "Test" + }) + + {:ok, members} = Ash.read(query, domain: Mv.Membership) + + # Should return max 10 members + assert length(members) == 10 + end + + test "excludes linked members" do + # Create member and link to user + {:ok, member1} = + Membership.create_member(%{ + first_name: "Linked", + last_name: "Member", + email: "linked@example.com" + }) + + {:ok, _user} = + Accounts.create_user(%{ + email: "user@example.com", + member: %{id: member1.id} + }) + + # Create unlinked member + {:ok, member2} = + Membership.create_member(%{ + first_name: "Unlinked", + last_name: "Member", + email: "unlinked@example.com" + }) + + # Search for "Member" + query = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{ + user_email: nil, + search_query: "Member" + }) + + {:ok, members} = Ash.read(query, domain: Mv.Membership) + + # Should only return unlinked member + member_ids = Enum.map(members, & &1.id) + refute member1.id in member_ids + assert member2.id in member_ids + end + end +end diff --git a/test/mv_web/user_live/form_debug2_test.exs b/test/mv_web/user_live/form_debug2_test.exs new file mode 100644 index 0000000..7847bb0 --- /dev/null +++ b/test/mv_web/user_live/form_debug2_test.exs @@ -0,0 +1,48 @@ +defmodule MvWeb.UserLive.FormDebug2Test do + use Mv.DataCase, async: true + + describe "direct ash query test" do + test "check if available_for_linking works in LiveView context" do + # Create an unlinked member + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "Jane", + last_name: "Smith", + email: "jane@example.com" + }) + + IO.puts("\n=== Created member: #{inspect(member.id)} ===") + + # Try the same query as in the LiveView + user_email_str = "user@example.com" + search_query_str = nil + + IO.puts("\n=== Calling Ash.read with domain: Mv.Membership ===") + + result = + Ash.read(Mv.Membership.Member, + domain: Mv.Membership, + action: :available_for_linking, + arguments: %{user_email: user_email_str, search_query: search_query_str} + ) + + IO.puts("Result: #{inspect(result)}") + + case result do + {:ok, members} -> + IO.puts("\n✓ Query succeeded, found #{length(members)} members") + + Enum.each(members, fn m -> + IO.puts(" - #{m.first_name} #{m.last_name} (#{m.email})") + end) + + # Apply filter + filtered = Mv.Membership.Member.filter_by_email_match(members, user_email_str) + IO.puts("\n✓ After filter_by_email_match: #{length(filtered)} members") + + {:error, error} -> + IO.puts("\n✗ Query failed: #{inspect(error)}") + end + end + end +end diff --git a/test/mv_web/user_live/form_debug_test.exs b/test/mv_web/user_live/form_debug_test.exs new file mode 100644 index 0000000..0731699 --- /dev/null +++ b/test/mv_web/user_live/form_debug_test.exs @@ -0,0 +1,52 @@ +defmodule MvWeb.UserLive.FormDebugTest do + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + + # Helper to setup authenticated connection and live view + defp setup_live_view(conn, path) do + conn = conn_with_oidc_user(conn, %{email: "admin@example.com"}) + live(conn, path) + end + + describe "debug member loading" do + test "check if members are loaded on mount", %{conn: conn} do + # Create an unlinked member + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "Jane", + last_name: "Smith", + email: "jane@example.com" + }) + + # Create user without member + user = create_test_user(%{email: "user@example.com"}) + + # Mount the form + {:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit") + + # Debug: Check what's in the HTML + IO.puts("\n=== HTML OUTPUT ===") + IO.puts(html) + IO.puts("\n=== END HTML ===") + + # Check socket assigns + IO.puts("\n=== SOCKET ASSIGNS ===") + assigns = :sys.get_state(view.pid).socket.assigns + IO.puts("available_members: #{inspect(assigns[:available_members])}") + IO.puts("show_member_dropdown: #{inspect(assigns[:show_member_dropdown])}") + IO.puts("member_search_query: #{inspect(assigns[:member_search_query])}") + IO.puts("user.member: #{inspect(assigns[:user].member)}") + IO.puts("\n=== END ASSIGNS ===") + + # Try to find the dropdown + assert has_element?(view, "input[name='member_search']") + + # Check if member is in the dropdown + if has_element?(view, "div[data-member-id='#{member.id}']") do + IO.puts("\n✓ Member found in dropdown") + else + IO.puts("\n✗ Member NOT found in dropdown") + end + end + end +end diff --git a/test/mv_web/user_live/form_member_linking_ui_test.exs b/test/mv_web/user_live/form_member_linking_ui_test.exs new file mode 100644 index 0000000..280dca9 --- /dev/null +++ b/test/mv_web/user_live/form_member_linking_ui_test.exs @@ -0,0 +1,433 @@ +defmodule MvWeb.UserLive.FormMemberLinkingUiTest do + @moduledoc """ + UI tests for member linking in UserLive.Form. + Tests dropdown behavior, fuzzy search, selection, and unlink workflow. + Related to Issue #168. + """ + + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias Mv.Accounts + alias Mv.Membership + + # Helper to setup authenticated connection for admin + defp setup_admin_conn(conn) do + conn_with_oidc_user(conn, %{email: "admin@example.com"}) + end + + describe "dropdown visibility" do + test "dropdown hidden on mount", %{conn: conn} do + conn = setup_admin_conn(conn) + html = conn |> live(~p"/users/new") |> render() + + # Dropdown should not be visible initially + refute html =~ ~r/role="listbox"/ + end + + test "dropdown shows after focus event", %{conn: conn} do + conn = setup_admin_conn(conn) + # Create unlinked members + create_unlinked_members(3) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Focus the member search input + view + |> element("#member-search-input") + |> render_focus() + + html = render(view) + + # Dropdown should now be visible + assert html =~ ~r/role="listbox"/ + end + + test "dropdown shows top 10 unlinked members on focus", %{conn: conn} do + # Create 15 unlinked members + members = create_unlinked_members(15) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Focus the member search input + view + |> element("#member-search-input") + |> render_focus() + + html = render(view) + + # Should show only 10 members + shown_members = Enum.take(members, 10) + hidden_members = Enum.drop(members, 10) + + for member <- shown_members do + assert html =~ member.first_name + end + + for member <- hidden_members do + refute html =~ member.first_name + end + end + end + + describe "fuzzy search" do + test "finds member with exact name", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Jonathan", + last_name: "Smith", + email: "jonathan.smith@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Type exact name + view + |> element("#member-search-input") + |> render_change(%{"member_search_query" => "Jonathan"}) + + html = render(view) + + assert html =~ "Jonathan" + assert html =~ "Smith" + end + + test "finds member with typo (Jon finds Jonathan)", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Jonathan", + last_name: "Smith", + email: "jonathan.smith@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Type with typo + view + |> element("#member-search-input") + |> render_change(%{"member_search_query" => "Jon"}) + + html = render(view) + + # Fuzzy search should find Jonathan + assert html =~ "Jonathan" + assert html =~ "Smith" + end + + test "finds member with partial substring", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Alexander", + last_name: "Williams", + email: "alex@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Type partial + view + |> element("#member-search-input") + |> render_change(%{"member_search_query" => "lex"}) + + html = render(view) + + assert html =~ "Alexander" + end + + test "returns empty for no matches", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "john@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Type something that doesn't match + view + |> element("#member-search-input") + |> render_change(%{"member_search_query" => "zzzzzzz"}) + + html = render(view) + + refute html =~ "John" + end + end + + describe "member selection" do + test "input field shows selected member name", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Alice", + last_name: "Johnson", + email: "alice@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Focus and search + view + |> element("#member-search-input") + |> render_focus() + + # Select member + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + html = render(view) + + # Input field should show member name + assert html =~ "Alice Johnson" + end + + test "confirmation box appears", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Bob", + last_name: "Williams", + email: "bob@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Focus input + view + |> element("#member-search-input") + |> render_focus() + + # Select member + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + html = render(view) + + # Confirmation box should appear + assert html =~ "Selected" + assert html =~ "Bob Williams" + assert html =~ "Save to confirm linking" + end + + test "hidden input stores member ID", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Charlie", + last_name: "Brown", + email: "charlie@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Focus input + view + |> element("#member-search-input") + |> render_focus() + + # Select member + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + # Check socket assigns (member ID should be stored) + assert view |> element("#user-form") |> has_element?() + end + end + + describe "email handling" do + test "links user and member with identical email successfully", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "David", + last_name: "Miller", + email: "david@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Fill user form with same email + view + |> form("#user-form", user: %{email: "david@example.com"}) + |> render_change() + + # Focus input + view + |> element("#member-search-input") + |> render_focus() + + # Select member + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + # Submit form + view + |> form("#user-form", user: %{email: "david@example.com"}) + |> render_submit() + + # Should succeed without errors + assert_redirected(view, ~p"/users") + end + + test "shows info when member has same email", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Emma", + last_name: "Davis", + email: "emma@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Fill user form with same email + view + |> form("#user-form", user: %{email: "emma@example.com"}) + |> render_change() + + html = render(view) + + # Should show info message about email conflict + assert html =~ "A member with this email already exists" + end + end + + describe "unlink workflow" do + test "unlink hides dropdown", %{conn: conn} do + # Create user with linked member + {:ok, member} = + Membership.create_member(%{ + first_name: "Frank", + last_name: "Wilson", + email: "frank@example.com" + }) + + {:ok, user} = + Accounts.create_user(%{ + email: "frank@example.com", + member: %{id: member.id} + }) + + {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit") + + # Click unlink button + view + |> element("button[phx-click='unlink_member']") + |> render_click() + + html = render(view) + + # Dropdown should not be visible + refute html =~ ~r/role="listbox"/ + end + + test "unlink shows warning", %{conn: conn} do + # Create user with linked member + {:ok, member} = + Membership.create_member(%{ + first_name: "Grace", + last_name: "Taylor", + email: "grace@example.com" + }) + + {:ok, user} = + Accounts.create_user(%{ + email: "grace@example.com", + member: %{id: member.id} + }) + + {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit") + + # Click unlink button + view + |> element("button[phx-click='unlink_member']") + |> render_click() + + html = render(view) + + # Should show warning + assert html =~ "Unlinking scheduled" + assert html =~ "Cannot select new member until saved" + end + + test "unlink disables input", %{conn: conn} do + # Create user with linked member + {:ok, member} = + Membership.create_member(%{ + first_name: "Henry", + last_name: "Anderson", + email: "henry@example.com" + }) + + {:ok, user} = + Accounts.create_user(%{ + email: "henry@example.com", + member: %{id: member.id} + }) + + {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit") + + # Click unlink button + view + |> element("button[phx-click='unlink_member']") + |> render_click() + + html = render(view) + + # Input should be disabled + assert html =~ ~r/disabled/ + end + + test "save re-enables member selection", %{conn: conn} do + # Create user with linked member + {:ok, member} = + Membership.create_member(%{ + first_name: "Isabel", + last_name: "Martinez", + email: "isabel@example.com" + }) + + {:ok, user} = + Accounts.create_user(%{ + email: "isabel@example.com", + member: %{id: member.id} + }) + + {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit") + + # Click unlink button + view + |> element("button[phx-click='unlink_member']") + |> render_click() + + # Submit form + view + |> form("#user-form") + |> render_submit() + + # Navigate back to edit + {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit") + + html = render(view) + + # Should now show member selection input (not disabled) + assert html =~ "member-search-input" + refute html =~ "Unlinking scheduled" + end + end + + # Helper functions + defp create_unlinked_members(count) do + for i <- 1..count do + {:ok, member} = + Membership.create_member(%{ + first_name: "FirstName#{i}", + last_name: "LastName#{i}", + email: "member#{i}@example.com" + }) + + member + end + end +end diff --git a/test/mv_web/user_live/form_test.exs b/test/mv_web/user_live/form_test.exs index 111ff42..b8f7313 100644 --- a/test/mv_web/user_live/form_test.exs +++ b/test/mv_web/user_live/form_test.exs @@ -281,4 +281,101 @@ defmodule MvWeb.UserLive.FormTest do assert edit_html =~ "Change Password" end end + + describe "member linking - display" do + test "shows linked member with unlink button when user has member", %{conn: conn} do + # Create member + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "john@example.com" + }) + + # Create user linked to member + user = create_test_user(%{email: "user@example.com"}) + {:ok, _updated_user} = Mv.Accounts.update_user(user, %{member: %{id: member.id}}) + + # Load form + {:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit") + + # Should show linked member section + assert html =~ "Linked Member" + assert html =~ "John Doe" + assert html =~ "user@example.com" + assert has_element?(view, "button[phx-click='unlink_member']") + assert html =~ "Unlink Member" + end + + test "shows member search field when user has no member", %{conn: conn} do + user = create_test_user(%{email: "user@example.com"}) + {:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit") + + # Should show member search section + assert html =~ "Linked Member" + assert has_element?(view, "input[phx-change='search_members']") + # Should not show unlink button + refute has_element?(view, "button[phx-click='unlink_member']") + end + end + + describe "member linking - workflow" do + test "selecting member and saving links member to user", %{conn: conn} do + # Create unlinked member + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "Jane", + last_name: "Smith", + email: "jane@example.com" + }) + + # Create user without member + user = create_test_user(%{email: "user@example.com"}) + {:ok, view, _html} = setup_live_view(conn, "/users/#{user.id}/edit") + + # Select member + view |> element("div[data-member-id='#{member.id}']") |> render_click() + + # Submit form + view + |> form("#user-form", user: %{email: "user@example.com"}) + |> render_submit() + + assert_redirected(view, "/users") + + # Verify member is linked + updated_user = Ash.get!(Mv.Accounts.User, user.id, domain: Mv.Accounts, load: [:member]) + assert updated_user.member.id == member.id + end + + test "unlinking member and saving removes member from user", %{conn: conn} do + # Create member + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "Bob", + last_name: "Wilson", + email: "bob@example.com" + }) + + # Create user linked to member + user = create_test_user(%{email: "user@example.com"}) + {:ok, _} = Mv.Accounts.update_user(user, %{member: %{id: member.id}}) + + {:ok, view, _html} = setup_live_view(conn, "/users/#{user.id}/edit") + + # Click unlink button + view |> element("button[phx-click='unlink_member']") |> render_click() + + # Submit form + view + |> form("#user-form", user: %{email: "user@example.com"}) + |> render_submit() + + assert_redirected(view, "/users") + + # Verify member is unlinked + updated_user = Ash.get!(Mv.Accounts.User, user.id, domain: Mv.Accounts, load: [:member]) + assert is_nil(updated_user.member) + end + end end diff --git a/test/mv_web/user_live/index_test.exs b/test/mv_web/user_live/index_test.exs index 6393e3b..c0b0275 100644 --- a/test/mv_web/user_live/index_test.exs +++ b/test/mv_web/user_live/index_test.exs @@ -410,4 +410,35 @@ defmodule MvWeb.UserLive.IndexTest do assert html =~ long_email end end + + describe "member linking display" do + test "displays linked member name in user list", %{conn: conn} do + # Create member + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "Alice", + last_name: "Johnson", + email: "alice@example.com" + }) + + # Create user linked to member + user = create_test_user(%{email: "user@example.com"}) + {:ok, _updated_user} = Mv.Accounts.update_user(user, %{member: %{id: member.id}}) + + # Create another user without member + _unlinked_user = create_test_user(%{email: "unlinked@example.com"}) + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/users") + + # Should show linked member name + assert html =~ "Alice Johnson" + # Should show user email + assert html =~ "user@example.com" + # Should show unlinked user + assert html =~ "unlinked@example.com" + # Should show "No member linked" or similar for unlinked user + assert html =~ "No member linked" + end + end end