From 1084f67f1f8ec31291edade4854916f406df89eb Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 10 Nov 2025 19:36:19 +0100 Subject: [PATCH 1/9] docs: Add roles and permissions architecture and implementation plan Complete RBAC system design with permission sets, Ash policies, and UI authorization. Implementation broken down into 18 issues across 4 sprints with TDD approach. Includes database schema, caching strategy, and comprehensive test coverage. --- docs/roles-and-permissions-architecture.md | 2279 ++++++++++++++++ ...les-and-permissions-implementation-plan.md | 2368 +++++++++++++++++ 2 files changed, 4647 insertions(+) create mode 100644 docs/roles-and-permissions-architecture.md create mode 100644 docs/roles-and-permissions-implementation-plan.md diff --git a/docs/roles-and-permissions-architecture.md b/docs/roles-and-permissions-architecture.md new file mode 100644 index 0000000..f9de090 --- /dev/null +++ b/docs/roles-and-permissions-architecture.md @@ -0,0 +1,2279 @@ +# Roles and Permissions Architecture + +**Project:** Mila - Membership Management System +**Feature:** Role-Based Access Control (RBAC) with Permission Sets +**Version:** 1.0 +**Last Updated:** 2025-11-10 +**Status:** Architecture Design + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Requirements Analysis](#requirements-analysis) +3. [Evaluated Approaches](#evaluated-approaches) +4. [Selected Architecture](#selected-architecture) +5. [Database Schema](#database-schema) +6. [Permission System Design](#permission-system-design) +7. [Implementation Details](#implementation-details) +8. [Future Extensions](#future-extensions) +9. [Migration Strategy](#migration-strategy) +10. [Security Considerations](#security-considerations) + +--- + +## Overview + +This document describes the architecture for implementing a flexible, scalable role-based access control (RBAC) system for the Mila membership management application. The system provides: + +- **Predefined Permission Sets** with configurable permissions +- **Dynamic Roles** that reference permission sets +- **Resource-level and Action-level** authorization +- **Page-level** access control for LiveView routes +- **Special handling** for credentials and linked user-member relationships +- **Future extensibility** for field-level permissions + +### Key Design Principles + +1. **Separation of Concerns:** Permission Sets (what you can do) vs. Roles (job titles/functions) +2. **Flexibility:** Admins can configure permissions at runtime via database +3. **Performance:** Leverage Ash Framework policies with ETS caching +4. **Extensibility:** Architecture supports future field-level granularity +5. **Consistency:** Single unified permission model for all resources + +--- + +## Requirements Analysis + +### Core Requirements + +Based on the project requirements, the system must support: + +#### Permission Sets (4 Predefined) + +1. **Own Data** - Users can only access their own data +2. **Read-Only** - Read access to all members, groups, and custom fields +3. **Normal User** - Read and write access to members and custom fields +4. **Admin** - Full access to all resources including user management + +#### Example Roles + +- **Mitglied** (Member) - Default role, own data access +- **Vorstand** (Board) - Access to members, not users +- **Kassenwart** (Treasurer) - Access to payment information +- **Buchhaltung** (Accounting) - Read-only access +- **Admin** - Full administrative access + +#### Authorization Granularity + +**Resource Level (Phase 1 - Now):** +- Member: read, create, update, destroy +- User: read, create, update, destroy +- PropertyType: read, create, update, destroy +- Property: read, create, update, destroy +- Role: read, create, update, destroy +- Payment (future): read, create, update, destroy + +**Page Level:** +- Control access to LiveView pages +- Pages are read-only access checks +- Edit pages require both page access AND resource write permission + +**Field Level (Phase 2 - Later):** +- Restrict read/write access to specific member fields +- Restrict access to specific custom field types +- Example: Treasurer sees payment_history, Board does not + +#### Special Cases + +1. **User Credentials:** + - Users can ALWAYS edit their own credentials (email, password) + - Only Admins can edit OTHER users' credentials + - Email field of members linked to users can only be edited by Admins + +2. **Required Fields:** + - When creating a member with required custom fields, user must be able to write those fields + - Even if user normally doesn't have write permission for that field type + - After creation, normal permissions apply + +3. **Payment History (Future):** + - Configurable per permission set + - Members may or may not see their own payment history + +4. **Linked User-Member Relationships:** + - Member email sync follows special rules + - User email is source of truth for linked members + +#### Constraints + +- Roles can be added, renamed, or removed by admins +- Permission sets are predefined but permissions are configurable +- Each user has exactly ONE role +- Roles cannot overlap (no multiple role assignment per user) +- "Mitglied" role is a system role and cannot be deleted +- Permission sets are system-defined and cannot be deleted + +--- + +## Evaluated Approaches + +We evaluated four different architectural approaches for implementing the authorization system: + +### Approach 1: Ash Policies + RBAC with JSONB Permissions + +**Description:** Store permissions as JSONB in the Role resource, use custom Ash Policy checks to evaluate them. + +**Database Structure:** +``` +roles (id, name, permissions_config: jsonb) +users (role_id) +``` + +**Permissions stored as:** +```json +{ + "resource_permissions": { + "Member": {"read": true, "update": false} + }, + "page_permissions": { + "/members": true + } +} +``` + +**Advantages:** +- ✅ Simple database schema (fewer tables) +- ✅ Flexible JSON structure +- ✅ Fast schema changes (no migrations needed) +- ✅ Easy to serialize/deserialize + +**Disadvantages:** +- ❌ No referential integrity on permission keys +- ❌ JSONB queries are less efficient than normalized tables +- ❌ Difficult to query "which roles have access to X?" +- ❌ Schema validation happens in application code +- ❌ No indexing on individual permissions +- ❌ Versioning of JSONB structure becomes complex + +**Verdict:** ❌ Not selected - JSONB makes querying and validation difficult + +--- + +### Approach 2: Hybrid RBAC + ABAC with Permission Matrix + +**Description:** Separate tables for every permission type with full granularity from day one. + +**Database Structure:** +``` +roles (id, name) +permissions (id, resource, action, field, condition) +role_permissions (role_id, permission_id, granted) +user_roles (user_id, role_id) +``` + +**Advantages:** +- ✅ Maximum flexibility +- ✅ Highly granular from the start +- ✅ Easy to add new permission types +- ✅ Audit trail built-in + +**Disadvantages:** +- ❌ Very complex database schema +- ❌ High JOIN overhead on every authorization check +- ❌ Over-engineered for current requirements +- ❌ Difficult to cache effectively +- ❌ Performance concerns with many permissions +- ❌ Complex to seed and maintain + +**Verdict:** ❌ Not selected - Too complex for current needs, over-engineering + +--- + +### Approach 3: Policy Graphs with Custom Authorizer + +**Description:** Use Ash Policies for action-level checks, custom Authorizer module for field-level filtering. + +**Database Structure:** +``` +roles (id, name, permission_config) +Custom Authorizer reads config and applies filters +``` + +**Advantages:** +- ✅ Best performance (optimized for Ash) +- ✅ Granular field-level control +- ✅ Can leverage Ash query optimization + +**Disadvantages:** +- ❌ Requires custom authorizer implementation (non-standard) +- ❌ More code to maintain +- ❌ Harder to test than declarative policies +- ❌ Mixes declarative (Policies) and imperative (Authorizer) approaches + +**Verdict:** ❌ Not selected - Too much custom code, reduces maintainability + +--- + +### Approach 4: Simple Role Enum (Quick Start) + +**Description:** Simple `:role` field on User with enum values, policies hardcoded in resources. + +**Database Structure:** +``` +users (role: :admin | :vorstand | :kassenwart | :member) +``` + +**Advantages:** +- ✅ Very simple to implement (1 week) +- ✅ No extra tables needed +- ✅ Fast performance +- ✅ Easy to understand + +**Disadvantages:** +- ❌ No dynamic permission configuration +- ❌ Requires code deployment to change permissions +- ❌ Can't add new roles without code changes +- ❌ Not extensible to field-level permissions +- ❌ Doesn't meet requirement for "configurable permissions" + +**Verdict:** ❌ Not selected - Doesn't meet core requirements + +--- + +## Selected Architecture + +### Approach 5: Permission Sets + Normalized Tables (Selected) + +**Description:** Hybrid approach that separates Permission Sets (what you can do) from Roles (who you are), with normalized database tables for queryability and Ash Policies for enforcement. + +**Key Innovation:** Introduce **Permission Sets** as an abstraction layer between Roles and actual Permissions. + +``` +Permission Set (4 predefined, defines capabilities) + ↓ +Role (many, references one Permission Set) + ↓ +User (each has one Role) +``` + +**Why This Approach?** + +1. **Meets Requirements:** + - ✅ Configurable permissions (stored in database) + - ✅ Dynamic role creation + - ✅ Extensible to field-level + - ✅ Admin UI can modify at runtime + +2. **Performance:** + - ✅ Normalized tables allow efficient queries + - ✅ Indexes on resource_name and action + - ✅ ETS cache for permission lookups + - ✅ Ash Policies translate to SQL filters + +3. **Maintainability:** + - ✅ Clear separation of concerns + - ✅ Standard Ash patterns (not custom authorizer) + - ✅ Testable with standard Ash policy tests + - ✅ Easy to understand and debug + +4. **Extensibility:** + - ✅ `field_name` column reserved for Phase 2 + - ✅ `scope` system handles "own" vs "all" vs "linked" + - ✅ New resources just add permission rows + - ✅ No code changes needed for new roles + +5. **Flexibility:** + - ✅ Permission Sets ensure consistency + - ✅ Roles can be renamed without changing permissions + - ✅ Multiple roles can share same permission set + - ✅ Admin can configure at runtime + +**Trade-offs Accepted:** +- More tables than JSONB approach (but better queryability) +- More rows than enum approach (but runtime configurable) +- Not as granular as full ABAC (but simpler to manage) + +--- + +## Database Schema + +### Entity Relationship Diagram + +``` +┌─────────────────────┐ +│ permission_sets │ +│─────────────────────│ +│ id (PK) │ +│ name │◄───────┐ +│ description │ │ +│ is_system │ │ +└─────────────────────┘ │ + │ + │ +┌─────────────────────────────┐│ +│ permission_set_resources ││ +│─────────────────────────────││ +│ id (PK) ││ +│ permission_set_id (FK) │┘ +│ resource_name │ +│ action │ +│ scope │ +│ field_name (nullable) │ +│ granted │ +└─────────────────────────────┘ + +┌─────────────────────────────┐ +│ permission_set_pages │ +│─────────────────────────────│ +│ id (PK) │ +│ permission_set_id (FK) │───┐ +│ page_path │ │ +└─────────────────────────────┘ │ + │ + │ +┌─────────────────────┐ │ +│ roles │ │ +│─────────────────────│ │ +│ id (PK) │ │ +│ name │ │ +│ description │ │ +│ permission_set_id │──────────┘ +│ is_system_role │ +└─────────────────────┘ + ▲ + │ + │ +┌─────────────────────┐ +│ users │ +│─────────────────────│ +│ id (PK) │ +│ email │ +│ hashed_password │ +│ oidc_id │ +│ member_id (FK) │ +│ role_id (FK) │◄──── Default: "Mitglied" role +└─────────────────────┘ +``` + +### Table Definitions + +#### `permission_sets` + +Defines the 4 core permission sets. These are system-defined and cannot be deleted. + +```sql +CREATE TABLE permission_sets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL UNIQUE, + description TEXT, + is_system BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +-- Indexes +CREATE INDEX idx_permission_sets_name ON permission_sets(name); +``` + +**Records:** +- `own_data` - Users can only access their own data +- `read_only` - Read access to all members and custom fields +- `normal_user` - Read and write access to members and custom fields +- `admin` - Full access to everything + +--- + +#### `permission_set_resources` + +Defines what actions each permission set can perform on which resources. + +```sql +CREATE TABLE permission_set_resources ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + permission_set_id UUID NOT NULL REFERENCES permission_sets(id) ON DELETE CASCADE, + resource_name VARCHAR(255) NOT NULL, -- "Member", "User", "PropertyType", etc. + action VARCHAR(50) NOT NULL, -- "read", "create", "update", "destroy" + scope VARCHAR(50), -- NULL/"all", "own", "linked" + field_name VARCHAR(255), -- NULL = all fields, else specific field (Phase 2) + granted BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +-- Indexes +CREATE INDEX idx_psr_permission_set ON permission_set_resources(permission_set_id); +CREATE INDEX idx_psr_resource_action ON permission_set_resources(resource_name, action); +CREATE UNIQUE INDEX idx_psr_unique ON permission_set_resources( + permission_set_id, resource_name, action, + COALESCE(scope, 'all'), COALESCE(field_name, '') +); +``` + +**Scope Values:** +- `NULL` or `"all"` - Permission applies to all entities of this resource +- `"own"` - Permission applies only to user's own data (user.id == actor.id) +- `"linked"` - Permission applies only to entities linked to user (e.g., member.user_id == actor.id) + +**Field Name (Phase 2):** +- `NULL` - Permission applies to all fields (Phase 1 default) +- `"field_name"` - Permission applies only to specific field (Phase 2) + +**Example Records:** +```sql +-- Own Data Permission Set: User can read their own User record +INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) +VALUES (own_data_id, 'User', 'read', 'own', true); + +-- Read-Only Permission Set: Can read all Members +INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) +VALUES (read_only_id, 'Member', 'read', 'all', true); + +-- Normal User Permission Set: Can update all Members +INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) +VALUES (normal_user_id, 'Member', 'update', 'all', true); + +-- Admin Permission Set: Can destroy all Members +INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) +VALUES (admin_id, 'Member', 'destroy', 'all', true); +``` + +--- + +#### `permission_set_pages` + +Defines which LiveView pages each permission set can access. + +```sql +CREATE TABLE permission_set_pages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + permission_set_id UUID NOT NULL REFERENCES permission_sets(id) ON DELETE CASCADE, + page_path VARCHAR(255) NOT NULL, -- "/members", "/members/:id/edit", "/admin" + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +-- Indexes +CREATE INDEX idx_psp_permission_set ON permission_set_pages(permission_set_id); +CREATE INDEX idx_psp_page_path ON permission_set_pages(page_path); +CREATE UNIQUE INDEX idx_psp_unique ON permission_set_pages(permission_set_id, page_path); +``` + +**Page Paths:** +- Static paths: `/members`, `/users`, `/admin` +- Dynamic paths: `/members/:id`, `/members/:id/edit` +- Must match Phoenix Router routes exactly + +**Important:** Page permissions are READ-ONLY access checks. If a user shouldn't access an edit page, they don't get the page permission. The actual write operation is controlled by resource permissions. + +**Example Records:** +```sql +-- Own Data: Only profile page +INSERT INTO permission_set_pages (permission_set_id, page_path) +VALUES (own_data_id, '/profile'); + +-- Read-Only: Member index and show pages +INSERT INTO permission_set_pages (permission_set_id, page_path) +VALUES + (read_only_id, '/members'), + (read_only_id, '/members/:id'); + +-- Normal User: Member pages including edit +INSERT INTO permission_set_pages (permission_set_id, page_path) +VALUES + (normal_user_id, '/members'), + (normal_user_id, '/members/new'), + (normal_user_id, '/members/:id'), + (normal_user_id, '/members/:id/edit'); + +-- Admin: All pages +INSERT INTO permission_set_pages (permission_set_id, page_path) +VALUES + (admin_id, '/members'), + (admin_id, '/members/new'), + (admin_id, '/members/:id'), + (admin_id, '/members/:id/edit'), + (admin_id, '/users'), + (admin_id, '/users/new'), + (admin_id, '/users/:id'), + (admin_id, '/users/:id/edit'), + (admin_id, '/admin'); +``` + +--- + +#### `roles` + +Defines user roles that reference one permission set each. + +```sql +CREATE TABLE roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL UNIQUE, + description TEXT, + permission_set_id UUID NOT NULL REFERENCES permission_sets(id) ON DELETE RESTRICT, + is_system_role BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +-- Indexes +CREATE INDEX idx_roles_name ON roles(name); +CREATE INDEX idx_roles_permission_set ON roles(permission_set_id); +``` + +**System Roles:** +- `is_system_role = true` for "Mitglied" (default role) +- System roles cannot be deleted +- Can be renamed but must always exist + +**Example Records:** +```sql +-- Mitglied (default role for all users) +INSERT INTO roles (name, description, permission_set_id, is_system_role) +VALUES ('Mitglied', 'Standard role for all members', own_data_id, true); + +-- Vorstand (board member with read access) +INSERT INTO roles (name, description, permission_set_id, is_system_role) +VALUES ('Vorstand', 'Board member with read access to all members', read_only_id, false); + +-- Kassenwart (treasurer with write access + payment info) +INSERT INTO roles (name, description, permission_set_id, is_system_role) +VALUES ('Kassenwart', 'Treasurer with access to payment information', normal_user_id, false); + +-- Buchhaltung (accounting with read access) +INSERT INTO roles (name, description, permission_set_id, is_system_role) +VALUES ('Buchhaltung', 'Accounting with read-only access', read_only_id, false); + +-- Admin (full access) +INSERT INTO roles (name, description, permission_set_id, is_system_role) +VALUES ('Admin', 'Full administrative access', admin_id, false); +``` + +--- + +#### `users` (Extended) + +Add `role_id` foreign key to existing users table. + +```sql +ALTER TABLE users +ADD COLUMN role_id UUID REFERENCES roles(id) ON DELETE RESTRICT; + +-- Set default to "Mitglied" role (via migration) +UPDATE users SET role_id = (SELECT id FROM roles WHERE name = 'Mitglied') WHERE role_id IS NULL; + +ALTER TABLE users ALTER COLUMN role_id SET NOT NULL; + +-- Index +CREATE INDEX idx_users_role ON users(role_id); +``` + +--- + +## Permission System Design + +### Permission Evaluation Flow + +``` +Request comes in (LiveView mount or Ash action) + ↓ +1. Load Current User with Role preloaded + ↓ +2. Check Page Permission (if LiveView) + - Query: permission_set_pages WHERE page_path = current_path + - If no match: DENY, redirect to "/" + ↓ +3. Ash Policy Check (for resource actions) + - Policy 1: Check "relates_to_actor" (own data) + - Policy 2: Check custom permission via DB + - Load permission_set_resources + - Match: resource_name, action, scope + - Evaluate scope: + * "own" → Filter: id == actor.id + * "linked" → Filter: user_id == actor.id + * "all" → No filter + - Policy 3: Default DENY + ↓ +4. Special Validations (if applicable) + - Member email change on linked member + - Required fields on create + ↓ +5. Execute Action or Render Page +``` + +### Scope Evaluation + +The `scope` field determines which subset of records a permission applies to: + +#### Scope: `"own"` + +Used for resources where user has direct ownership. + +**Applicable to:** `User` + +**Filter Logic:** +```elixir +{:filter, expr(id == ^actor.id)} +``` + +**Example:** +- Own Data permission set has `User.read` with scope `"own"` +- User can only read their own User record +- Query becomes: `SELECT * FROM users WHERE id = $actor_id` + +--- + +#### Scope: `"linked"` + +Used for resources linked to user via intermediate relationship. + +**Applicable to:** `Member`, `Property`, `Payment` (future) + +**Filter Logic:** +```elixir +# For Member +{:filter, expr(user_id == ^actor.id)} + +# For Property (traverses relationship) +{:filter, expr(member.user_id == ^actor.id)} + +# For Payment (future, traverses relationship) +{:filter, expr(member.user_id == ^actor.id)} +``` + +**Example:** +- Own Data permission set has `Member.read` with scope `"linked"` +- User can only read Members linked to them (member.user_id == actor.id) +- If user has no linked member: no results +- Query becomes: `SELECT * FROM members WHERE user_id = $actor_id` + +--- + +#### Scope: `"all"` or `NULL` + +Used for full access to all records of a resource. + +**Applicable to:** All resources + +**Filter Logic:** +```elixir +:authorized # No filter, all records allowed +``` + +**Example:** +- Read-Only permission set has `Member.read` with scope `"all"` +- User can read all Members +- Query becomes: `SELECT * FROM members` (no WHERE clause for authorization) + +--- + +### Policy Implementation in Ash Resources + +Each Ash resource defines policies that check permissions: + +```elixir +defmodule Mv.Membership.Member do + use Ash.Resource, ... + + policies do + # Policy 1: Users can always access their own linked member data + # This bypasses permission checks for own data + policy action_type([:read, :update]) do + description "Users can always access their own member data if linked" + authorize_if relates_to_actor_via(:user) + end + + # Policy 2: Check database permissions + # This is where permission_set_resources table is queried + policy action_type([:read, :create, :update, :destroy]) do + description "Check if actor's role has permission for this action" + authorize_if Mv.Authorization.Checks.HasResourcePermission.for_action() + end + + # Policy 3: Default deny + # If no policy matched, forbid access + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() + end + end +end +``` + +**Important:** Policy order matters! First matching policy wins. + +--- + +### Custom Policy Check Implementation + +```elixir +defmodule Mv.Authorization.Checks.HasResourcePermission do + @moduledoc """ + Custom Ash Policy Check that evaluates database-stored permissions. + + Queries the permission_set_resources table based on actor's role + and evaluates scope to return appropriate filter. + """ + + use Ash.Policy.Check + + @impl true + def type, do: :filter + + @impl true + def match?(actor, context, _opts) do + resource = context.resource + action = context.action + + # Load actor's permission set (with caching) + case get_permission_set(actor) do + nil -> + :forbidden + + permission_set -> + # Query permission_set_resources table + check_permission(permission_set.id, resource, action.name, actor, context) + end + end + + defp get_permission_set(nil), do: nil + defp get_permission_set(actor) do + # Try cache first (ETS) + case Mv.Authorization.PermissionCache.get_permission_set(actor.id) do + {:ok, permission_set} -> + permission_set + + :miss -> + # Load from database: user → role → permission_set + load_and_cache_permission_set(actor) + end + end + + defp load_and_cache_permission_set(actor) do + case Ash.load(actor, role: :permission_set) do + {:ok, user_with_relations} -> + permission_set = user_with_relations.role.permission_set + Mv.Authorization.PermissionCache.put_permission_set(actor.id, permission_set) + permission_set + + _ -> + nil + end + end + + defp check_permission(permission_set_id, resource, action, actor, context) do + resource_name = resource |> Module.split() |> List.last() + + # Query permission_set_resources + query = + Mv.Authorization.PermissionSetResource + |> Ash.Query.filter( + permission_set_id == ^permission_set_id and + resource_name == ^resource_name and + action == ^action and + is_nil(field_name) # Phase 1: only resource-level + ) + + case Ash.read_one(query) do + {:ok, permission} -> + evaluate_permission(permission, actor, context) + + _ -> + :forbidden + end + end + + defp evaluate_permission(%{granted: false}, _actor, _context) do + :forbidden + end + + defp evaluate_permission(%{granted: true, scope: nil}, _actor, _context) do + :authorized + end + + defp evaluate_permission(%{granted: true, scope: "all"}, _actor, _context) do + :authorized + end + + defp evaluate_permission(%{granted: true, scope: "own"}, actor, _context) do + # Return filter expression for Ash + {:filter, expr(id == ^actor.id)} + end + + defp evaluate_permission(%{granted: true, scope: "linked"}, actor, context) do + resource = context.resource + + # Generate appropriate filter based on resource + case resource do + Mv.Membership.Member -> + {:filter, expr(user_id == ^actor.id)} + + Mv.Membership.Property -> + {:filter, expr(member.user_id == ^actor.id)} + + # Add more resources as needed + + _ -> + :forbidden + end + end +end +``` + +--- + +## Implementation Details + +### Phase 1: Resource and Page Level Permissions + +**Timeline:** Sprint 1-2 (2-3 weeks) + +**Deliverables:** +1. Database migrations for all permission tables +2. Ash resources for PermissionSet, Role, PermissionSetResource, PermissionSetPage +3. Custom policy checks +4. Permission cache (ETS) +5. Router plug for page permissions +6. Seeds for 4 permission sets and 5 roles +7. Admin UI for role management +8. Tests for all permission scenarios + +**Not Included in Phase 1:** +- Field-level permissions (field_name is always NULL) +- Payment history (resource doesn't exist yet) +- Groups (not yet planned) + +--- + +### Special Cases Implementation + +#### 1. User Credentials - Always Editable by Owner + +**Requirement:** Users can always edit their own email and password, regardless of permission set. + +**Implementation:** + +```elixir +defmodule Mv.Accounts.User do + policies do + # Policy 1: Users can ALWAYS read and update their own credentials + # This comes BEFORE permission checks + policy action_type([:read, :update]) do + description "Users can always access and update their own credentials" + authorize_if expr(id == ^actor(:id)) + end + + # Policy 2: Check permission set (for admins accessing other users) + policy action_type([:read, :create, :update, :destroy]) do + authorize_if Mv.Authorization.Checks.HasResourcePermission.for_action() + end + + # Policy 3: Default deny + policy action_type([:read, :create, :update, :destroy]) do + forbid_if always() + end + end +end +``` + +**Result:** +- Mitglied role: Can edit own User record (own email/password) +- Admin role: Can edit ANY User record (including others' credentials) +- Other roles: Cannot access User resource unless specifically granted + +--- + +#### 2. Member Email for Linked Members - Admin Only + +**Requirement:** If a member is linked to a user, only admins can edit the member's email field. + +**Implementation:** + +```elixir +defmodule Mv.Membership.Member do + validations do + validate fn changeset, context -> + # Only check if email is being changed + if Ash.Changeset.changing_attribute?(changeset, :email) do + member = changeset.data + actor = context.actor + + # Load member's user relationship + case Ash.load(member, :user) do + {:ok, %{user: %{id: _user_id}}} -> + # Member IS linked to a user + # Check if actor has permission to edit ALL users + if has_permission_for_all_users?(actor) do + :ok + else + {:error, + field: :email, + message: "Only admins can edit email of members linked to users"} + end + + {:ok, %{user: nil}} -> + # Member is NOT linked + # Normal Member.update permission applies + :ok + + {:error, _} -> + :ok + end + else + :ok + end + end + end + + defp has_permission_for_all_users?(actor) do + # Check if actor's permission set has User.update with scope="all" + permission_set = get_permission_set(actor) + + Mv.Authorization.PermissionSetResource + |> Ash.Query.filter( + permission_set_id == ^permission_set.id and + resource_name == "User" and + action == "update" and + scope == "all" and + granted == true + ) + |> Ash.exists?() + end +end +``` + +**Result:** +- Admin: Can edit email of any member (including linked ones) +- Normal User/Read-Only: Can edit email of unlinked members only +- Attempting to edit email of linked member without permission: Validation error + +--- + +#### 3. Required Custom Fields on Member Creation + +**Requirement:** When creating a member with required custom fields, user must be able to set those fields even if they normally don't have permission. + +**Implementation:** + +For Phase 1, this is not an issue because: +- PropertyType.required flag exists but isn't enforced yet +- No field-level permissions exist yet +- If user has Member.create permission, they can set Properties + +For Phase 2 (when field-level permissions exist): + +```elixir +defmodule Mv.Membership.Property do + actions do + create :create_property do + # Special handling for required properties during member creation + change Mv.Authorization.Changes.AllowRequiredPropertyOnMemberCreate + end + end +end + +defmodule Mv.Authorization.Changes.AllowRequiredPropertyOnMemberCreate do + use Ash.Resource.Change + + def change(changeset, _opts, context) do + # Check if this is part of a member creation + if creating_member?(context) do + property_type_id = Ash.Changeset.get_attribute(changeset, :property_type_id) + + # Load PropertyType + case Ash.get(Mv.Membership.PropertyType, property_type_id) do + {:ok, %{required: true}} -> + # This is a required field, allow creation even without normal permission + # Set special context flag + Ash.Changeset.set_context(changeset, :bypass_property_permission, true) + + _ -> + changeset + end + else + changeset + end + end +end +``` + +--- + +### Page Permission Implementation + +**Router Configuration:** + +```elixir +defmodule MvWeb.Router do + use MvWeb, :router + import MvWeb.Authorization + + # Pipeline with permission check + pipeline :require_page_permission do + plug :put_secure_browser_headers + plug :fetch_current_user + plug MvWeb.Plugs.CheckPagePermission + end + + scope "/", MvWeb do + pipe_through [:browser, :require_authenticated_user, :require_page_permission] + + # These routes automatically check page permissions + live "/members", MemberLive.Index, :index + live "/members/new", MemberLive.Index, :new + live "/members/:id", MemberLive.Show, :show + live "/members/:id/edit", MemberLive.Index, :edit + + live "/users", UserLive.Index, :index + live "/users/new", UserLive.Index, :new + live "/users/:id", UserLive.Show, :show + live "/users/:id/edit", UserLive.Index, :edit + + live "/property-types", PropertyTypeLive.Index, :index + live "/property-types/new", PropertyTypeLive.Index, :new + live "/property-types/:id/edit", PropertyTypeLive.Index, :edit + + live "/admin", AdminLive.Dashboard, :index + end +end +``` + +**Page Permission Plug:** + +```elixir +defmodule MvWeb.Plugs.CheckPagePermission do + @moduledoc """ + Plug that checks if current user has permission to access the current page. + + Queries permission_set_pages table based on user's role → permission_set. + """ + + import Plug.Conn + import Phoenix.Controller + + 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 + conn + |> put_flash(:error, "You don't have permission to access this page.") + |> redirect(to: "/") + |> halt() + end + end + + defp get_page_path(conn) do + # Extract route template from conn + # "/members/:id/edit" from actual "/members/123/edit" + case conn.private[:phoenix_route] do + {_, _, _, route_template, _} -> route_template + _ -> conn.request_path + end + end + + defp has_page_permission?(nil, _page_path), do: false + defp has_page_permission?(user, page_path) do + # Try cache first + case Mv.Authorization.PermissionCache.get_page_permission(user.id, page_path) do + {:ok, has_permission} -> + has_permission + + :miss -> + # Load from database and cache + has_permission = check_page_permission_db(user, page_path) + Mv.Authorization.PermissionCache.put_page_permission(user.id, page_path, has_permission) + has_permission + end + end + + defp check_page_permission_db(user, page_path) do + # Load user → role → permission_set + case Ash.load(user, role: :permission_set) do + {:ok, user_with_relations} -> + permission_set_id = user_with_relations.role.permission_set.id + + # Check if permission_set_pages has this page + Mv.Authorization.PermissionSetPage + |> Ash.Query.filter( + permission_set_id == ^permission_set_id and + page_path == ^page_path + ) + |> Ash.exists?() + + _ -> + false + end + end +end +``` + +**Important:** Both page permission AND resource permission must be true for edit operations: +- User needs `/members/:id/edit` page permission to see the page +- User needs `Member.update` permission to actually save changes +- If user has page permission but not resource permission: page loads but save fails + +--- + +### Permission Cache Implementation + +**ETS Cache for Performance:** + +```elixir +defmodule Mv.Authorization.PermissionCache do + @moduledoc """ + ETS-based cache for user permissions to avoid database lookups on every request. + + Cache stores: + - User's permission_set (user_id → permission_set) + - Page permissions (user_id + page_path → boolean) + - Resource permissions (user_id + resource + action → permission) + + Cache is invalidated when: + - User's role changes + - Role's permission_set changes + - Permission set's permissions change + """ + + use GenServer + + @table_name :permission_cache + + # Client API + + def start_link(_) do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def get_permission_set(user_id) do + case :ets.lookup(@table_name, {:permission_set, user_id}) do + [{_, permission_set}] -> {:ok, permission_set} + [] -> :miss + end + end + + def put_permission_set(user_id, permission_set) do + :ets.insert(@table_name, {{:permission_set, user_id}, permission_set}) + :ok + end + + def get_page_permission(user_id, page_path) do + case :ets.lookup(@table_name, {:page, user_id, page_path}) do + [{_, has_permission}] -> {:ok, has_permission} + [] -> :miss + end + end + + def put_page_permission(user_id, page_path, has_permission) do + :ets.insert(@table_name, {{:page, user_id, page_path}, has_permission}) + :ok + end + + def invalidate_user(user_id) do + # Delete all entries for this user + :ets.match_delete(@table_name, {{:permission_set, user_id}, :_}) + :ets.match_delete(@table_name, {{:page, user_id, :_}, :_}) + :ok + end + + def invalidate_all do + :ets.delete_all_objects(@table_name) + :ok + end + + # Server Callbacks + + def init(_) do + table = :ets.new(@table_name, [ + :set, + :public, + :named_table, + read_concurrency: true, + write_concurrency: true + ]) + {:ok, %{table: table}} + end +end +``` + +**Cache Invalidation Strategy:** + +```elixir +defmodule Mv.Authorization.Role do + # After updating role's permission_set + changes do + change after_action(fn changeset, role, _context -> + # Invalidate cache for all users with this role + invalidate_users_with_role(role.id) + {:ok, role} + end), on: [:update] + end + + defp invalidate_users_with_role(role_id) do + # Find all users with this role + users = + Mv.Accounts.User + |> Ash.Query.filter(role_id == ^role_id) + |> Ash.read!() + + # Invalidate each user's cache + Enum.each(users, fn user -> + Mv.Authorization.PermissionCache.invalidate_user(user.id) + end) + end +end + +defmodule Mv.Authorization.PermissionSetResource do + # After updating permissions + changes do + change after_action(fn changeset, permission, _context -> + # Invalidate all users with this permission set + invalidate_permission_set(permission.permission_set_id) + {:ok, permission} + end), on: [:create, :update, :destroy] + end + + defp invalidate_permission_set(permission_set_id) do + # Find all roles with this permission set + roles = + Mv.Authorization.Role + |> Ash.Query.filter(permission_set_id == ^permission_set_id) + |> Ash.read!() + + # Invalidate all users with these roles + Enum.each(roles, fn role -> + invalidate_users_with_role(role.id) + end) + end +end +``` + +--- + +### UI-Level Authorization + +**Requirement:** The user interface should only display links, buttons, and fields that the user has permission to access. This improves UX and prevents confusion. + +**Key Principles:** +1. **Navigation Links:** Hide links to pages the user cannot access +2. **Action Buttons:** Hide "Edit", "Delete", "New" buttons when user lacks permissions +3. **Form Fields:** In Phase 2, hide fields the user cannot read/write +4. **Proactive UI:** Never show a clickable element that would result in "Forbidden" + +--- + +#### Implementation Approach + +**Helper Module:** `MvWeb.Authorization` + +```elixir +defmodule MvWeb.Authorization do + @moduledoc """ + UI-level authorization helpers for LiveView. + + These helpers check permissions and determine what UI elements to show. + They work in conjunction with Ash Policies (which are the actual enforcement). + """ + + alias Mv.Authorization.PermissionCache + alias Mv.Authorization + + @doc """ + Checks if actor can perform action on resource. + + ## Examples + + # In LiveView template + <%= if can?(@current_user, :update, Mv.Membership.Member) do %> + + <% end %> + + # In LiveView module + if can?(socket.assigns.current_user, :create, Mv.Membership.PropertyType) do + # Show "New Custom Field" button + end + """ + def can?(nil, _action, _resource), do: false + + def can?(user, action, resource) when is_atom(action) and is_atom(resource) do + resource_name = resource_name(resource) + + # Check cache first + case get_permission_from_cache(user.id, resource_name, action) do + {:ok, result} -> result + :miss -> check_permission_from_db(user, resource_name, action) + end + end + + @doc """ + Checks if actor can access a specific page path. + + ## Examples + + # In navigation component + <%= if can_access_page?(@current_user, "/members") do %> + <.link navigate="/members">Members + <% end %> + """ + def can_access_page?(nil, _page_path), do: false + + def can_access_page?(user, page_path) do + # Check cache first + case PermissionCache.get_page_permission(user.id, page_path) do + {:ok, result} -> result + :miss -> check_page_permission_from_db(user, page_path) + end + end + + @doc """ + Checks if actor can perform action on a specific record. + + This respects scope restrictions (own, linked, all). + + ## Examples + + # Show edit button only if user can edit THIS member + <%= if can?(@current_user, :update, member) do %> + + <% end %> + """ + def can?(nil, _action, _record), do: false + + def can?(user, action, %resource{} = record) when is_atom(action) do + resource_name = resource_name(resource) + + # First check if user has any permission for this action + case get_permission_from_cache(user.id, resource_name, action) do + {:ok, false} -> + false + + {:ok, true} -> + # User has permission, now check scope + check_scope_for_record(user, action, resource, record) + + :miss -> + check_permission_and_scope_from_db(user, action, resource, record) + end + end + + # Private helpers + + defp resource_name(Mv.Accounts.User), do: "User" + defp resource_name(Mv.Membership.Member), do: "Member" + defp resource_name(Mv.Membership.Property), do: "Property" + defp resource_name(Mv.Membership.PropertyType), do: "PropertyType" + + defp get_permission_from_cache(user_id, resource_name, action) do + # Try to get from cache + # Returns {:ok, true}, {:ok, false}, or :miss + case PermissionCache.get_permission_set(user_id) do + {:ok, permission_set} -> + # Check if this permission set has the permission + has_permission = + permission_set.resources + |> Enum.any?(fn p -> + p.resource_name == resource_name and + p.action == to_string(action) and + p.granted == true + end) + + {:ok, has_permission} + + :miss -> + :miss + end + end + + defp check_permission_from_db(user, resource_name, action) do + # Load user's role and permission set + user = Ash.load!(user, role: [permission_set: :resources]) + + has_permission = + user.role.permission_set.resources + |> Enum.any?(fn p -> + p.resource_name == resource_name and + p.action == to_string(action) and + p.granted == true + end) + + # Cache the entire permission set + PermissionCache.put_permission_set(user.id, user.role.permission_set) + + has_permission + end + + defp check_page_permission_from_db(user, page_path) do + user = Ash.load!(user, role: [permission_set: :pages]) + + has_access = + user.role.permission_set.pages + |> Enum.any?(fn p -> p.page_path == page_path end) + + # Cache this specific page permission + PermissionCache.put_page_permission(user.id, page_path, has_access) + + has_access + end + + defp check_scope_for_record(user, action, resource, record) do + # Load the permission to check scope + user = Ash.load!(user, role: [permission_set: :resources]) + resource_name = resource_name(resource) + + permission = + user.role.permission_set.resources + |> Enum.find(fn p -> + p.resource_name == resource_name and + p.action == to_string(action) and + p.granted == true + end) + + case permission do + nil -> + false + + %{scope: "all"} -> + true + + %{scope: "own"} when resource == Mv.Accounts.User -> + # Check if record.id == user.id + record.id == user.id + + %{scope: "linked"} when resource == Mv.Membership.Member -> + # Check if record.user_id == user.id + record_with_user = Ash.load!(record, :user) + case record_with_user.user do + nil -> false + %{id: user_id} -> user_id == user.id + end + + %{scope: "linked"} when resource == Mv.Membership.Property -> + # Check if record.member.user_id == user.id + record_with_member = Ash.load!(record, member: :user) + case record_with_member.member do + nil -> false + %{user: nil} -> false + %{user: %{id: user_id}} -> user_id == user.id + end + + _ -> + false + end + end + + defp check_permission_and_scope_from_db(user, action, resource, record) do + case check_permission_from_db(user, resource_name(resource), action) do + false -> false + true -> check_scope_for_record(user, action, resource, record) + end + end +end +``` + +--- + +#### Usage in LiveView Templates + +**Navigation Component:** + +```heex + + +``` + +**Index Page with Action Buttons:** + +```heex + + + +<.table rows={@members}> + <:col :let={member} label="Name"> + <%= member.first_name %> <%= member.last_name %> + + + <:col :let={member} label="Email"> + <%= member.email %> + + + <:col :let={member} label="Actions"> + + <.link navigate={~p"/members/#{member}"} class="btn-secondary"> + Show + + + + <%= if can?(@current_user, :update, member) do %> + <.link patch={~p"/members/#{member}/edit"} class="btn-secondary"> + Edit + + <% end %> + + + <%= if can?(@current_user, :destroy, member) do %> + <.button phx-click="delete" phx-value-id={member.id} class="btn-danger"> + Delete + + <% end %> + + +``` + +**Show Page:** + +```heex + + + +
+
+
First Name
+
<%= @member.first_name %>
+ +
Last Name
+
<%= @member.last_name %>
+ +
Email
+
<%= @member.email %>
+ + + + <%= if can_read_field?(@current_user, @member, :birth_date) do %> +
Birth Date
+
<%= @member.birth_date %>
+ <% end %> +
+
+``` + +--- + +#### Usage in LiveView Modules + +**Mount Hook:** + +```elixir +defmodule MvWeb.MemberLive.Index do + use MvWeb, :live_view + + import MvWeb.Authorization + + def mount(_params, _session, socket) do + current_user = socket.assigns.current_user + + # Check if user can even access this page + # (This is redundant with router plug, but provides better UX) + unless can_access_page?(current_user, "/members") do + {:ok, + socket + |> put_flash(:error, "You don't have permission to access this page") + |> redirect(to: ~p"/")} + else + members = list_members(current_user) + + {:ok, + socket + |> assign(:members, members) + |> assign(:can_create, can?(current_user, :create, Mv.Membership.Member))} + end + end + + defp list_members(current_user) do + # Ash automatically filters based on policies + Mv.Membership.Member + |> Ash.read!(actor: current_user) + end + + def handle_event("delete", %{"id" => id}, socket) do + current_user = socket.assigns.current_user + member = Ash.get!(Mv.Membership.Member, id, actor: current_user) + + # Double-check permission (though Ash will also enforce) + if can?(current_user, :destroy, member) do + case Ash.destroy(member, actor: current_user) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:info, "Member deleted successfully") + |> push_navigate(to: ~p"/members")} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Failed to delete member")} + end + else + {:noreply, put_flash(socket, :error, "Permission denied")} + end + end +end +``` + +--- + +#### Performance Considerations + +**Caching:** +- Permission checks use ETS cache (from PermissionCache) +- First call loads from DB and caches +- Subsequent calls use cache +- Cache invalidated on role/permission changes + +**Batch Checking:** + +For tables with many rows, we can optimize by checking once per resource type: + +```elixir +def mount(_params, _session, socket) do + current_user = socket.assigns.current_user + + members = list_members(current_user) + + # Check permissions once for the resource type + can_update_any = can?(current_user, :update, Mv.Membership.Member) + can_destroy_any = can?(current_user, :destroy, Mv.Membership.Member) + + # Then check scope for each member (if needed) + members_with_permissions = + Enum.map(members, fn member -> + %{ + member: member, + can_update: can_update_any && can_update_this?(current_user, member), + can_destroy: can_destroy_any && can_destroy_this?(current_user, member) + } + end) + + {:ok, + socket + |> assign(:members_with_permissions, members_with_permissions) + |> assign(:can_create, can?(current_user, :create, Mv.Membership.Member))} +end +``` + +--- + +#### Testing UI Authorization + +**Test Strategy:** + +```elixir +# test/mv_web/member_live/index_test.exs +defmodule MvWeb.MemberLive.IndexTest do + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + describe "UI authorization for Mitglied role" do + test "does not show 'New Member' button", %{conn: conn} do + user = create_user_with_role("Mitglied") + member = create_member_linked_to_user(user) + conn = log_in_user(conn, user) + + {:ok, view, html} = live(conn, ~p"/members") + + refute html =~ "New Member" + refute has_element?(view, "a", "New Member") + end + + test "shows only 'Show' button for own member", %{conn: conn} do + user = create_user_with_role("Mitglied") + member = create_member_linked_to_user(user) + conn = log_in_user(conn, user) + + {:ok, view, html} = live(conn, ~p"/members") + + # Show button should exist + assert has_element?(view, "a[href='/members/#{member.id}']", "Show") + + # Edit and Delete buttons should NOT exist + refute has_element?(view, "a[href='/members/#{member.id}/edit']", "Edit") + refute has_element?(view, "button[phx-click='delete']", "Delete") + end + + test "does not show 'Users' link in navigation", %{conn: conn} do + user = create_user_with_role("Mitglied") + conn = log_in_user(conn, user) + + {:ok, view, html} = live(conn, ~p"/") + + refute html =~ "Users" + refute has_element?(view, "a[href='/users']", "Users") + end + end + + describe "UI authorization for Kassenwart role" do + test "shows 'New Member' button", %{conn: conn} do + user = create_user_with_role("Kassenwart") + conn = log_in_user(conn, user) + + {:ok, view, html} = live(conn, ~p"/members") + + assert html =~ "New Member" + assert has_element?(view, "a", "New Member") + end + + test "shows Edit and Delete buttons for all members", %{conn: conn} do + user = create_user_with_role("Kassenwart") + member1 = create_member() + member2 = create_member() + conn = log_in_user(conn, user) + + {:ok, view, _html} = live(conn, ~p"/members") + + # Both members should have Edit and Delete buttons + assert has_element?(view, "a[href='/members/#{member1.id}/edit']", "Edit") + assert has_element?(view, "a[href='/members/#{member2.id}/edit']", "Edit") + + # Note: Using a more flexible selector for delete buttons + assert has_element?(view, ~s([phx-click="delete"][phx-value-id="#{member1.id}"])) + assert has_element?(view, ~s([phx-click="delete"][phx-value-id="#{member2.id}"])) + end + end + + describe "UI authorization for Admin role" do + test "shows all navigation links", %{conn: conn} do + admin = create_user_with_role("Admin") + conn = log_in_user(conn, admin) + + {:ok, view, html} = live(conn, ~p"/") + + assert html =~ "Members" + assert html =~ "Users" + assert html =~ "Custom Fields" + assert html =~ "Roles" + + assert has_element?(view, "a[href='/members']", "Members") + assert has_element?(view, "a[href='/users']", "Users") + assert has_element?(view, "a[href='/property-types']", "Custom Fields") + assert has_element?(view, "a[href='/admin/roles']", "Roles") + end + end +end +``` + +--- + +## Future Extensions + +### Phase 2: Field-Level Permissions + +**Timeline:** Sprint 4-5 (after Phase 1 is stable) + +**Goal:** Allow permission sets to restrict access to specific fields of resources. + +#### Database Schema (Already Prepared) + +The `permission_set_resources.field_name` column is already in place: +- `NULL` = all fields (Phase 1 default) +- `"field_name"` = specific field (Phase 2) + +#### Implementation Approach + +**Option A: Blacklist Approach (Recommended)** + +Permission with `field_name = NULL` grants access to all fields. Additional rows with `granted = false` deny specific fields. + +```sql +-- Normal User can read all Member fields +INSERT INTO permission_set_resources + (permission_set_id, resource_name, action, field_name, granted) +VALUES + (normal_user_id, 'Member', 'read', NULL, true); + +-- But NOT birth_date +INSERT INTO permission_set_resources + (permission_set_id, resource_name, action, field_name, granted) +VALUES + (normal_user_id, 'Member', 'read', 'birth_date', false); + +-- And NOT notes +INSERT INTO permission_set_resources + (permission_set_id, resource_name, action, field_name, granted) +VALUES + (normal_user_id, 'Member', 'read', 'notes', false); +``` + +**Evaluation Logic:** +1. Check if there's a permission with `field_name = NULL` and `granted = true` +2. If yes: Load all deny entries (`field_name != NULL` and `granted = false`) +3. Deselect those fields from query + +**Advantages:** +- Default is "allow all fields" +- Only need to specify exceptions +- Easy to add new fields (automatically included) + +**Disadvantages:** +- Can't have different scopes per field (all fields have same scope) + +--- + +**Option B: Whitelist Approach** + +No permission with `field_name = NULL`. Only explicit `granted = true` entries allow access. + +```sql +-- Read-Only can ONLY read these specific Member fields +INSERT INTO permission_set_resources + (permission_set_id, resource_name, action, field_name, granted) +VALUES + (read_only_id, 'Member', 'read', 'first_name', true), + (read_only_id, 'Member', 'read', 'last_name', true), + (read_only_id, 'Member', 'read', 'email', true), + (read_only_id, 'Member', 'read', 'phone_number', true); +-- birth_date, notes, etc. are implicitly denied +``` + +**Evaluation Logic:** +1. Check if there's a permission with `field_name = NULL` and `granted = true` +2. If no: Load all allow entries (`field_name != NULL` and `granted = true`) +3. Only select those fields in query + +**Advantages:** +- Explicit "allow" model (more secure default) +- Could have different scopes per field (future feature) + +**Disadvantages:** +- Tedious to specify every allowed field +- New fields are denied by default (requires permission update) + +--- + +#### Custom Preparation for Field Filtering + +```elixir +defmodule Mv.Authorization.Preparations.FilterFieldsByPermission do + use Ash.Resource.Preparation + + def prepare(query, _opts, context) do + actor = context.actor + action = query.action + resource = query.resource + + # Get denied fields for this actor/resource/action + denied_fields = get_denied_fields(actor, resource, action.name) + + # Deselect denied fields + Ash.Query.deselect(query, denied_fields) + end + + defp get_denied_fields(actor, resource, action) do + permission_set = get_permission_set(actor) + resource_name = resource |> Module.split() |> List.last() + + # Query denied fields (blacklist approach) + Mv.Authorization.PermissionSetResource + |> Ash.Query.filter( + permission_set_id == ^permission_set.id and + resource_name == ^resource_name and + action == ^action and + not is_nil(field_name) and + granted == false + ) + |> Ash.read!() + |> Enum.map(& &1.field_name) + |> Enum.map(&String.to_existing_atom/1) + end +end + +# Add to resources in Phase 2 +defmodule Mv.Membership.Member do + preparations do + prepare Mv.Authorization.Preparations.FilterFieldsByPermission + end +end +``` + +--- + +#### Custom Fields (Properties) Field-Level + +For custom fields, field-level permissions work differently: + +**Approach:** `field_name` stores the PropertyType name (not Property field) + +```sql +-- Read-Only can ONLY see "membership_number" custom field +INSERT INTO permission_set_resources + (permission_set_id, resource_name, action, field_name, granted) +VALUES + (read_only_id, 'Property', 'read', 'membership_number', true); + +-- All other PropertyTypes are denied +``` + +**Implementation:** + +```elixir +defmodule Mv.Authorization.Preparations.FilterPropertiesByType do + use Ash.Resource.Preparation + + def prepare(query, _opts, context) do + actor = context.actor + + # Get allowed PropertyType names + allowed_types = get_allowed_property_types(actor) + + if allowed_types == :all do + query + else + # Filter: only load Properties of allowed types + Ash.Query.filter(query, property_type.name in ^allowed_types) + end + end +end +``` + +--- + +### Phase 3: Payment History Permissions + +**Timeline:** When Payment resource is implemented + +**Goal:** Control access to payment-related data. + +#### Implementation + +```sql +-- Add Payment resource permissions +INSERT INTO permission_set_resources + (permission_set_id, resource_name, action, scope, granted) +VALUES + -- Own Data: Can see own payment history (optional) + (own_data_id, 'Payment', 'read', 'linked', false), -- Default: disabled + + -- Read-Only: Cannot see payment history + (read_only_id, 'Payment', 'read', 'all', false), + + -- Normal User (Kassenwart): Can see and edit all payment history + (normal_user_id, 'Payment', 'read', 'all', true), + (normal_user_id, 'Payment', 'create', 'all', true), + (normal_user_id, 'Payment', 'update', 'all', true), + + -- Admin: Full access + (admin_id, 'Payment', 'read', 'all', true), + (admin_id, 'Payment', 'create', 'all', true), + (admin_id, 'Payment', 'update', 'all', true), + (admin_id, 'Payment', 'destroy', 'all', true); +``` + +**Configuration UI:** + +Admin UI will allow toggling "Members can view their own payment history" which updates the Own Data permission set: + +```elixir +# Toggle payment history visibility for members +def toggle_member_payment_visibility(enabled) do + own_data_ps = get_permission_set_by_name("own_data") + + # Find or create Payment.read permission + permission = + PermissionSetResource + |> Ash.Query.filter( + permission_set_id == ^own_data_ps.id and + resource_name == "Payment" and + action == "read" and + scope == "linked" + ) + |> Ash.read_one!() + + # Update granted flag + Ash.Changeset.for_update(permission, :update, %{granted: enabled}) + |> Ash.update!() +end +``` + +--- + +### Phase 4: Groups and Group Permissions + +**Timeline:** TBD (future feature) + +**Goal:** Group members and apply permissions per group. + +#### Possible Approaches + +**Option 1: Group-scoped Permissions** + +Add `group_id` to permission_set_resources: + +```sql +ALTER TABLE permission_set_resources +ADD COLUMN group_id UUID REFERENCES groups(id); + +-- Normal User can only edit members in "Youth Group" +INSERT INTO permission_set_resources + (permission_set_id, resource_name, action, scope, group_id, granted) +VALUES + (normal_user_id, 'Member', 'update', 'all', youth_group_id, true); +``` + +**Option 2: Group-based Roles** + +Roles can have group restrictions: + +```sql +ALTER TABLE roles +ADD COLUMN group_id UUID REFERENCES groups(id); + +-- "Youth Leader" role only has permissions for youth group +INSERT INTO roles (name, permission_set_id, group_id) +VALUES ('Youth Leader', normal_user_id, youth_group_id); +``` + +**Decision:** Deferred until Groups feature is designed. + +--- + +## Migration Strategy + +### Migration Plan + +#### Sprint 1: Foundation + +**Week 1:** +- Create database migrations for permission tables +- Create Ash resources (PermissionSet, Role, PermissionSetResource, PermissionSetPage) +- Add role_id to users table +- Create seed script for 4 permission sets + +**Week 2:** +- Implement custom policy checks +- Implement permission cache (ETS) +- Create seeds for 5 roles with permissions +- Write tests for permission evaluation + +#### Sprint 2: Integration + +**Week 3:** +- Implement router plug for page permissions +- Update all existing resources with policies +- Handle special cases (user credentials, member email) +- Integration tests for common scenarios + +**Week 4:** +- Admin UI for role management +- Admin UI for assigning roles to users +- Documentation and user guide +- Performance testing and optimization + +--- + +### Data Migration + +#### Existing Users + +All existing users will be assigned the "Mitglied" (Member) role by default: + +```sql +-- Migration: Set default role for existing users +UPDATE users +SET role_id = (SELECT id FROM roles WHERE name = 'Mitglied') +WHERE role_id IS NULL; +``` + +#### Backward Compatibility + +**Phase 1:** +- No existing authorization system to maintain +- Clean slate implementation + +**Phase 2 (Field-Level):** +- Existing permission_set_resources with `field_name = NULL` continue to work +- No migration needed, just add new field-specific permissions + +--- + +## Security Considerations + +### Threat Model + +#### 1. Privilege Escalation + +**Threat:** User tries to escalate privileges by manipulating requests. + +**Mitigation:** +- All authorization enforced server-side (Ash Policies) +- Actor is verified via session +- No client-side permission checks that can be bypassed +- Cache invalidation ensures stale permissions aren't used + +#### 2. Permission Cache Poisoning + +**Threat:** Attacker manipulates ETS cache to grant unauthorized access. + +**Mitigation:** +- ETS table is server-side only +- Cache keys include user_id (can't access other users' cache) +- Cache invalidated on any permission change +- Fallback to database if cache returns unexpected data + +#### 3. SQL Injection via Scope Filters + +**Threat:** Malicious actor value causes SQL injection in scope filters. + +**Mitigation:** +- All filters use Ash's expr() macro with parameterized queries +- Actor ID is always a UUID (validated by database) +- No string concatenation in filter construction + +#### 4. Permission Set Modification + +**Threat:** Unauthorized user modifies permission sets or roles. + +**Mitigation:** +- Permission Sets have `is_system = true` and cannot be deleted +- Role management requires Admin permission +- Audit log (future) tracks all permission changes + +#### 5. Bypass via Direct Database Access + +**Threat:** Code bypasses Ash and queries database directly. + +**Mitigation:** +- Code review enforces "always use Ash" policy +- No raw SQL in application code +- Database credentials secured via environment variables + +#### 6. Session Hijacking + +**Threat:** Attacker steals session and impersonates user. + +**Mitigation:** +- Handled by AshAuthentication (out of scope for this document) +- Sessions use signed tokens +- HTTPS in production + +--- + +### Audit Logging (Future) + +For compliance and debugging, implement audit log: + +```sql +CREATE TABLE permission_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + action VARCHAR(50), -- "role_changed", "permission_modified" + resource_type VARCHAR(255), + resource_id UUID, + old_value JSONB, + new_value JSONB, + timestamp TIMESTAMP NOT NULL DEFAULT now() +); +``` + +--- + +## Appendix + +### Glossary + +- **Permission Set:** A predefined collection of permissions defining what actions can be performed +- **Role:** A named job function (e.g., "Treasurer") that references one permission set +- **Resource:** An Ash resource (e.g., Member, User, PropertyType) +- **Action:** An Ash action (e.g., read, create, update, destroy) +- **Scope:** The subset of records a permission applies to (own, linked, all) +- **Actor:** The current user making a request +- **Page Permission:** Access control for LiveView routes +- **Field-Level Permission:** Restriction on specific fields of a resource (Phase 2) + +### Permission Set Summary + +| Permission Set | Use Case | Example Roles | Resources Access | +|---------------|----------|---------------|------------------| +| **own_data** | Users accessing only their own data | Mitglied | User (own), Member (linked), Property (linked) | +| **read_only** | Users who can view but not edit | Vorstand, Buchhaltung | Member (all, read), Property (all, read) | +| **normal_user** | Users who can edit members and properties | Kassenwart | Member (all, read/write), Property (all, read/write) | +| **admin** | Full administrative access | Admin | All resources (all, full CRUD) | + +### Resource Permission Matrix + +| Resource | Own Data | Read-Only | Normal User | Admin | +|----------|----------|-----------|-------------|-------| +| **Member** | Linked: R/W | All: R | All: R/W | All: Full | +| **User** | Own: R/W | None | None | All: Full | +| **PropertyType** | All: R | All: R | All: R | All: Full | +| **Property** | Linked: R/W | All: R | All: R/W | All: Full | +| **Role** | None | All: R | None | All: Full | +| **Payment** (future) | Linked: R (config) | None | All: R/W | All: Full | + +### Page Permission Matrix + +| Page Path | Own Data | Read-Only | Normal User | Admin | +|-----------|----------|-----------|-------------|-------| +| `/profile` | ✅ | ✅ | ✅ | ✅ | +| `/members` | ❌ | ✅ | ✅ | ✅ | +| `/members/:id` | ❌ | ✅ | ✅ | ✅ | +| `/members/new` | ❌ | ❌ | ✅ | ✅ | +| `/members/:id/edit` | ❌ | ❌ | ✅ | ✅ | +| `/users` | ❌ | ❌ | ❌ | ✅ | +| `/users/:id/edit` | ❌ | ❌ | ❌ | ✅ | +| `/property-types` | ❌ | ✅ | ✅ | ✅ | +| `/property-types/new` | ❌ | ❌ | ❌ | ✅ | +| `/admin` | ❌ | ❌ | ❌ | ✅ | + +--- + +## Document History + +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0 | 2025-11-10 | Architecture Team | Initial architecture design | + +--- + +**End of 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..92bd262 --- /dev/null +++ b/docs/roles-and-permissions-implementation-plan.md @@ -0,0 +1,2368 @@ +# Roles and Permissions - Implementation Plan + +**Project:** Mila - Membership Management System +**Feature:** Role-Based Access Control (RBAC) Implementation +**Version:** 1.0 +**Last Updated:** 2025-11-10 +**Status:** Ready for Implementation + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Test-Driven Development Approach](#test-driven-development-approach) +3. [Issue Dependency Graph](#issue-dependency-graph) +4. [Sprint 1: Foundation](#sprint-1-foundation-weeks-1-2) +5. [Sprint 2: Policy System](#sprint-2-policy-system-weeks-2-3) +6. [Sprint 3: Special Cases & Seeds](#sprint-3-special-cases--seeds-week-3) +7. [Sprint 4: UI & Integration](#sprint-4-ui--integration-week-4) +8. [Parallel Work Opportunities](#parallel-work-opportunities) +9. [Summary](#summary) +10. [Data Migration](#data-migration) + +--- + +## Overview + +This document provides a detailed, step-by-step implementation plan for the Roles and Permissions system. The implementation is broken down into **18 small, focused issues** that can be worked on in parallel where possible. + +**Key Principles:** +- **Test-Driven Development (TDD):** Write tests first, then implement +- **Small, Focused Issues:** Each issue is 1-4 days of work +- **Parallelization:** Multiple issues can be worked on simultaneously +- **Clear Dependencies:** Dependency graph shows what must be completed first +- **Definition of Done:** Each issue has clear completion criteria + +**Related Documents:** +- [Architecture Design](./roles-and-permissions-architecture.md) - Complete system architecture and design decisions + +--- + +## Test-Driven Development Approach + +This feature will be implemented using Test-Driven Development (TDD): + +### TDD Workflow + +1. **Red Phase - Write Failing Tests First:** + - For each issue, write tests that define expected behavior + - Tests should fail because functionality doesn't exist yet + - Tests serve as specification and documentation + +2. **Green Phase - Implement Minimum Code:** + - Write just enough code to make tests pass + - Focus on functionality, not perfection + - Get to green as quickly as possible + +3. **Refactor Phase - Clean Up:** + - Clean up code while keeping tests green + - Improve structure, naming, and organization + - Ensure code follows guidelines + +4. **Integration Phase - Ensure Components Work Together:** + - Write integration tests + - Test cross-component interactions + - Verify complete user flows + +### Test Coverage Goals + +| Test Type | Coverage Goal | Description | +|-----------|---------------|-------------| +| **Unit Tests** | >90% | Policy checks, permission evaluation, cache operations | +| **Integration Tests** | >80% | Cross-resource authorization, special cases | +| **LiveView Tests** | >85% | Page permission enforcement, UI interactions | +| **E2E Tests** | 100% of user flows | Complete journeys for each role | + +### Test Organization + +``` +test/ +├── mv/ +│ ├── authorization/ +│ │ ├── schema_test.exs # Issue #1 +│ │ ├── permission_set_test.exs # Issue #2 +│ │ ├── role_test.exs # Issue #3 +│ │ ├── permission_set_resource_test.exs # Issue #4 +│ │ ├── permission_set_page_test.exs # Issue #5 +│ │ ├── permission_cache_test.exs # Issue #6 +│ │ ├── checks/ +│ │ │ └── has_resource_permission_test.exs # Issue #7 +│ │ └── integration_test.exs # Issue #16 +│ ├── accounts/ +│ │ └── user_authorization_test.exs # Issue #9 +│ └── membership/ +│ ├── member_authorization_test.exs # Issue #8 +│ ├── member_email_validation_test.exs # Issue #13 +│ ├── property_authorization_test.exs # Issue #10 +│ └── property_type_authorization_test.exs # Issue #11 +├── mv_web/ +│ ├── authorization_test.exs # Issue #15 +│ ├── plugs/ +│ │ └── check_page_permission_test.exs # Issue #12 +│ ├── components/ +│ │ └── layouts/ +│ │ └── navbar_test.exs # Issue #17 +│ ├── member_live/ +│ │ └── index_test.exs # Issue #17 +│ ├── role_live/ +│ │ └── index_test.exs # Issue #16 +│ └── user_live/ +│ └── index_test.exs # Issue #16, #17 +└── seeds/ + └── authorization_seeds_test.exs # Issue #14 +``` + +--- + +## Issue Dependency Graph + +``` + ┌──────────────────┐ + │ Issue #1 │ + │ DB Schema │ + └────────┬─────────┘ + │ + ┌────────────┴────────────┐ + │ │ + ┌───────▼────────┐ ┌───────▼────────┐ + │ Issue #2 │ │ Issue #3 │ + │ PermSet Res │ │ Role Res │ + └───────┬────────┘ └───────┬────────┘ + │ │ + └────────────┬────────────┘ + │ + ┌────────▼─────────┐ + │ Issue #4 │ + │ Permission │ + │ Set Resources │ + └────────┬─────────┘ + │ + ┌────────▼─────────┐ + │ Issue #5 │ + │ Permission │ + │ Set Pages │ + └────────┬─────────┘ + │ + ┌────────────┴────────────┐ + │ │ + ┌───────▼────────┐ ┌───────▼────────┐ + │ Issue #6 │ │ Issue #7 │ + │ Cache │ │ Policy Check │ + └───────┬────────┘ └───────┬────────┘ + │ │ + └────────────┬────────────┘ + │ + ┌────────────┴────────────┐ + │ │ + ┌───────▼────────┐ ┌───────▼────────┐ + │ Issue #8 │ │ Issue #9 │ + │ Member Pol │ │ User Pol │ + └───────┬────────┘ └───────┬────────┘ + │ │ + ┌───────▼────────┐ ┌───────▼────────┐ + │ Issue #10 │ │ Issue #11 │ + │ Property Pol │ │ PropType Pol │ + └───────┬────────┘ └───────┬────────┘ + │ │ + └────────────┬────────────┘ + │ + ┌────────▼─────────┐ + │ Issue #12 │ + │ Page Perms │ + └────────┬─────────┘ + │ + ┌────────────┴────────────┐ + │ │ + ┌───────▼────────┐ ┌───────▼────────┐ + │ Issue #13 │ │ Issue #14 │ + │ Email Valid │ │ Seeds │ + └───────┬────────┘ └───────┬────────┘ + │ │ + └────────────┬────────────┘ + │ + ┌────────▼─────────┐ + │ Issue #15 │ + │ UI Auth Helper │ + └────────┬─────────┘ + │ + ┌────────▼─────────┐ + │ Issue #16 │ + │ Admin UI │ + └────────┬─────────┘ + │ + ┌────────▼─────────┐ + │ Issue #17 │ + │ UI Auth in │ + │ LiveViews │ + └────────┬─────────┘ + │ + ┌────────▼─────────┐ + │ Issue #18 │ + │ Integration │ + │ Tests │ + └──────────────────┘ +``` + +--- + +## Sprint 1: Foundation (Weeks 1-2) + +### Issue #1: Database Schema Migrations + +**Size:** S (1-2 days) +**Dependencies:** None +**Can work in parallel:** Yes (foundational) +**Assignable to:** Backend Developer + +#### Description + +Create all database tables for the permission system. + +#### Tasks + +1. Create migration for `permission_sets` table +2. Create migration for `permission_set_resources` table +3. Create migration for `permission_set_pages` table +4. Create migration for `roles` table +5. Add `role_id` column to `users` table + +#### Test Strategy (TDD) + +**Write these tests FIRST, before implementing:** + +```elixir +# test/mv/authorization/schema_test.exs +defmodule Mv.Authorization.SchemaTest do + use Mv.DataCase, async: true + + describe "permission_sets table" do + test "has correct columns and constraints" do + # Verify table exists + assert table_exists?("permission_sets") + + # Verify columns + assert has_column?("permission_sets", "id", :uuid) + assert has_column?("permission_sets", "name", :string) + assert has_column?("permission_sets", "description", :text) + assert has_column?("permission_sets", "is_system", :boolean) + assert has_column?("permission_sets", "created_at", :timestamp) + assert has_column?("permission_sets", "updated_at", :timestamp) + + # Verify indexes + assert has_index?("permission_sets", ["name"], unique: true) + end + + test "name must be unique" do + # Insert first record + {:ok, _} = Repo.insert(%{ + __struct__: "permission_sets", + name: "test", + is_system: false + }) + + # Try to insert duplicate + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%{ + __struct__: "permission_sets", + name: "test", + is_system: false + }) + end + end + end + + describe "permission_set_resources table" do + test "has correct columns and constraints" do + assert table_exists?("permission_set_resources") + + assert has_column?("permission_set_resources", "permission_set_id", :uuid) + assert has_column?("permission_set_resources", "resource_name", :string) + assert has_column?("permission_set_resources", "action", :string) + assert has_column?("permission_set_resources", "scope", :string) + assert has_column?("permission_set_resources", "field_name", :string) + assert has_column?("permission_set_resources", "granted", :boolean) + end + + test "has unique constraint on permission_set + resource + action + scope + field" do + ps_id = insert_permission_set() + + # Insert first record + {:ok, _} = Repo.insert(%{ + __struct__: "permission_set_resources", + permission_set_id: ps_id, + resource_name: "Member", + action: "read", + scope: "all", + field_name: nil, + granted: true + }) + + # Try to insert duplicate + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%{ + __struct__: "permission_set_resources", + permission_set_id: ps_id, + resource_name: "Member", + action: "read", + scope: "all", + field_name: nil, + granted: true + }) + end + end + + test "cascade deletes when permission_set is deleted" do + ps_id = insert_permission_set() + psr_id = insert_permission_set_resource(ps_id) + + # Delete permission set + Repo.delete_all(from p in "permission_sets", where: p.id == ^ps_id) + + # Permission set resource should be deleted + refute Repo.exists?(from p in "permission_set_resources", where: p.id == ^psr_id) + end + end + + describe "permission_set_pages table" do + test "has correct columns" do + assert table_exists?("permission_set_pages") + + assert has_column?("permission_set_pages", "permission_set_id", :uuid) + assert has_column?("permission_set_pages", "page_path", :string) + end + + test "has unique constraint on permission_set + page_path" do + ps_id = insert_permission_set() + + {:ok, _} = Repo.insert(%{ + __struct__: "permission_set_pages", + permission_set_id: ps_id, + page_path: "/members" + }) + + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%{ + __struct__: "permission_set_pages", + permission_set_id: ps_id, + page_path: "/members" + }) + end + end + end + + describe "roles table" do + test "has correct columns" do + assert table_exists?("roles") + + assert has_column?("roles", "id", :uuid) + assert has_column?("roles", "name", :string) + assert has_column?("roles", "description", :text) + assert has_column?("roles", "permission_set_id", :uuid) + assert has_column?("roles", "is_system_role", :boolean) + end + + test "permission_set_id is required" do + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%{ + __struct__: "roles", + name: "Test Role", + permission_set_id: nil + }) + end + end + end + + describe "users table extension" do + test "has role_id column" do + assert has_column?("users", "role_id", :uuid) + end + + test "role_id references roles table" do + assert has_foreign_key?("users", "role_id", "roles", "id") + end + end +end +``` + +#### Implementation Steps + +1. Run tests (they should fail) +2. Create migration file: `priv/repo/migrations/TIMESTAMP_add_authorization_tables.exs` +3. Implement migrations following the schema in architecture document +4. Run migrations +5. Run tests (they should pass) + +#### Definition of Done + +- [ ] All migrations run successfully +- [ ] Database schema matches design +- [ ] All indexes created correctly +- [ ] Foreign key constraints work as expected +- [ ] All schema tests pass +- [ ] Migration can be rolled back successfully +- [ ] Migration is idempotent (can run multiple times) + +--- + +### Issue #2: PermissionSet Ash Resource + +**Size:** S (1 day) +**Dependencies:** #1 +**Can work in parallel:** After #1 +**Assignable to:** Backend Developer + +#### Description + +Create Ash resource for PermissionSet with basic CRUD operations. + +#### Tasks + +1. Create `lib/mv/authorization/permission_set.ex` +2. Create `lib/mv/authorization.ex` (Domain module) +3. Define attributes (name, description, is_system) +4. Define actions (read, create, update, destroy) +5. Add validation to prevent deletion of system permission sets +6. Add code_interface for easy access +7. Add resource to Authorization domain + +#### Test Strategy (TDD) + +```elixir +# test/mv/authorization/permission_set_test.exs +defmodule Mv.Authorization.PermissionSetTest do + use Mv.DataCase, async: true + + alias Mv.Authorization.PermissionSet + + describe "create_permission_set/1" do + test "creates permission set with valid attributes" do + attrs = %{ + name: "test_set", + description: "Test Permission Set", + is_system: false + } + + assert {:ok, ps} = Mv.Authorization.create_permission_set(attrs) + assert ps.name == "test_set" + assert ps.description == "Test Permission Set" + assert ps.is_system == false + end + + test "requires name" do + attrs = %{description: "Test", is_system: false} + + assert {:error, error} = Mv.Authorization.create_permission_set(attrs) + assert error.errors + |> Enum.any?(fn e -> e.field == :name end) + end + + test "prevents duplicate names" do + attrs = %{name: "duplicate", is_system: false} + + {:ok, _} = Mv.Authorization.create_permission_set(attrs) + + assert {:error, error} = Mv.Authorization.create_permission_set(attrs) + # Check for unique constraint error + assert error.errors + |> Enum.any?(fn e -> + e.field == :name and String.contains?(e.message, "unique") + end) + end + + test "defaults is_system to false" do + attrs = %{name: "test"} + + {:ok, ps} = Mv.Authorization.create_permission_set(attrs) + assert ps.is_system == false + end + end + + describe "list_permission_sets/0" do + test "returns all permission sets" do + create_permission_set(%{name: "set1"}) + create_permission_set(%{name: "set2"}) + + sets = Mv.Authorization.list_permission_sets() + assert length(sets) == 2 + end + + test "returns empty list when no permission sets" do + sets = Mv.Authorization.list_permission_sets() + assert sets == [] + end + end + + describe "get_permission_set/1" do + test "gets permission set by id" do + {:ok, ps} = create_permission_set(%{name: "test"}) + + {:ok, fetched} = Mv.Authorization.get_permission_set(ps.id) + assert fetched.id == ps.id + assert fetched.name == "test" + end + + test "returns error when permission set not found" do + assert {:error, _} = Mv.Authorization.get_permission_set(Ecto.UUID.generate()) + end + end + + describe "update_permission_set/2" do + test "updates permission set attributes" do + {:ok, ps} = create_permission_set(%{name: "original"}) + + {:ok, updated} = Mv.Authorization.update_permission_set(ps, %{ + name: "updated", + description: "Updated description" + }) + + assert updated.name == "updated" + assert updated.description == "Updated description" + end + + test "cannot update is_system for system permission sets" do + {:ok, ps} = create_permission_set(%{name: "test", is_system: true}) + + assert {:error, _} = Mv.Authorization.update_permission_set(ps, %{ + is_system: false + }) + end + end + + describe "destroy_permission_set/1" do + test "destroys non-system permission set" do + {:ok, ps} = create_permission_set(%{name: "test", is_system: false}) + + assert {:ok, _} = Mv.Authorization.destroy_permission_set(ps) + assert {:error, _} = Mv.Authorization.get_permission_set(ps.id) + end + + test "prevents deletion of system permission sets" do + {:ok, ps} = create_permission_set(%{name: "system_set", is_system: true}) + + assert {:error, error} = Mv.Authorization.destroy_permission_set(ps) + assert error.errors + |> Enum.any?(fn e -> + String.contains?(e.message, "system") + end) + end + + test "system permission set still exists after failed deletion" do + {:ok, ps} = create_permission_set(%{name: "system_set", is_system: true}) + + Mv.Authorization.destroy_permission_set(ps) + + {:ok, fetched} = Mv.Authorization.get_permission_set(ps.id) + assert fetched.id == ps.id + end + end + + # Helper functions + defp create_permission_set(attrs) do + default_attrs = %{name: "test_#{System.unique_integer()}", is_system: false} + Mv.Authorization.create_permission_set(Map.merge(default_attrs, attrs)) + end +end +``` + +#### Definition of Done + +- [ ] PermissionSet resource created with all attributes +- [ ] Authorization domain module created +- [ ] All CRUD actions implemented +- [ ] System permission sets cannot be deleted +- [ ] System permission sets cannot have is_system changed +- [ ] Code interface works for all actions +- [ ] All resource tests pass +- [ ] Resource added to domain + +--- + +### Issue #3: Role Ash Resource + +**Size:** S (1 day) +**Dependencies:** #1, #2 +**Can work in parallel:** After #1 and #2 +**Assignable to:** Backend Developer + +#### Description + +Create Ash resource for Role with relationship to PermissionSet and cache invalidation. + +#### Tasks + +1. Create `lib/mv/authorization/role.ex` +2. Define attributes (name, description, is_system_role) +3. Define `belongs_to` relationship to PermissionSet +4. Define actions (read, create, update, destroy) +5. Add validation to prevent deletion of system roles +6. Add cache invalidation after update (prepare for Issue #6) +7. Add code_interface +8. Add resource to Authorization domain + +#### Test Strategy (TDD) + +```elixir +# test/mv/authorization/role_test.exs +defmodule Mv.Authorization.RoleTest do + use Mv.DataCase, async: true + + alias Mv.Authorization.Role + + describe "create_role/1" do + test "creates role linked to permission set" do + ps = create_permission_set() + + attrs = %{ + name: "Test Role", + description: "Test Description", + permission_set_id: ps.id, + is_system_role: false + } + + assert {:ok, role} = Mv.Authorization.create_role(attrs) + assert role.name == "Test Role" + assert role.permission_set_id == ps.id + end + + test "requires permission_set_id" do + attrs = %{name: "Test Role"} + + assert {:error, error} = Mv.Authorization.create_role(attrs) + assert error.errors + |> Enum.any?(fn e -> e.field == :permission_set_id end) + end + + test "requires name" do + ps = create_permission_set() + attrs = %{permission_set_id: ps.id} + + assert {:error, error} = Mv.Authorization.create_role(attrs) + assert error.errors + |> Enum.any?(fn e -> e.field == :name end) + end + + test "prevents duplicate names" do + ps = create_permission_set() + attrs = %{name: "Duplicate", permission_set_id: ps.id} + + {:ok, _} = Mv.Authorization.create_role(attrs) + + assert {:error, error} = Mv.Authorization.create_role(attrs) + assert error.errors + |> Enum.any?(fn e -> e.field == :name end) + end + + test "defaults is_system_role to false" do + ps = create_permission_set() + attrs = %{name: "Test", permission_set_id: ps.id} + + {:ok, role} = Mv.Authorization.create_role(attrs) + assert role.is_system_role == false + end + end + + describe "list_roles/0" do + test "returns all roles" do + ps = create_permission_set() + create_role(%{name: "Role1", permission_set_id: ps.id}) + create_role(%{name: "Role2", permission_set_id: ps.id}) + + roles = Mv.Authorization.list_roles() + assert length(roles) == 2 + end + end + + describe "get_role/1" do + test "loads permission_set relationship" do + ps = create_permission_set(%{name: "Test Set"}) + {:ok, role} = create_role(%{name: "Test", permission_set_id: ps.id}) + + {:ok, fetched} = Mv.Authorization.get_role(role.id, load: [:permission_set]) + assert fetched.permission_set.id == ps.id + assert fetched.permission_set.name == "Test Set" + end + end + + describe "update_role/2" do + test "updates role attributes" do + ps = create_permission_set() + {:ok, role} = create_role(%{name: "Original", permission_set_id: ps.id}) + + {:ok, updated} = Mv.Authorization.update_role(role, %{ + name: "Updated", + description: "New description" + }) + + assert updated.name == "Updated" + assert updated.description == "New description" + end + + test "can change permission_set_id" do + ps1 = create_permission_set() + ps2 = create_permission_set() + {:ok, role} = create_role(%{name: "Test", permission_set_id: ps1.id}) + + {:ok, updated} = Mv.Authorization.update_role(role, %{ + permission_set_id: ps2.id + }) + + assert updated.permission_set_id == ps2.id + end + + test "invalidates cache for all users with this role" do + # This test will be fully implemented in Issue #6 + # For now, just verify the change callback is registered + ps = create_permission_set() + {:ok, role} = create_role(%{name: "Test", permission_set_id: ps.id}) + + # Update role + {:ok, _updated} = Mv.Authorization.update_role(role, %{description: "New"}) + + # TODO: Add cache invalidation assertions in Issue #6 + end + end + + describe "destroy_role/1" do + test "destroys non-system role" do + ps = create_permission_set() + {:ok, role} = create_role(%{ + name: "Test", + permission_set_id: ps.id, + is_system_role: false + }) + + assert {:ok, _} = Mv.Authorization.destroy_role(role) + assert {:error, _} = Mv.Authorization.get_role(role.id) + end + + test "prevents deletion of system roles" do + ps = create_permission_set() + {:ok, role} = create_role(%{ + name: "System Role", + permission_set_id: ps.id, + is_system_role: true + }) + + assert {:error, error} = Mv.Authorization.destroy_role(role) + assert error.errors + |> Enum.any?(fn e -> String.contains?(e.message, "system") end) + end + + test "system role still exists after failed deletion" do + ps = create_permission_set() + {:ok, role} = create_role(%{ + name: "System", + permission_set_id: ps.id, + is_system_role: true + }) + + Mv.Authorization.destroy_role(role) + + {:ok, fetched} = Mv.Authorization.get_role(role.id) + assert fetched.id == role.id + end + end + + # Helper functions + defp create_permission_set(attrs \\ %{}) do + default = %{name: "ps_#{System.unique_integer()}", is_system: false} + {:ok, ps} = Mv.Authorization.create_permission_set(Map.merge(default, attrs)) + ps + end + + defp create_role(attrs) do + default = %{name: "role_#{System.unique_integer()}"} + Mv.Authorization.create_role(Map.merge(default, attrs)) + end +end +``` + +#### Definition of Done + +- [ ] Role resource created with all attributes +- [ ] Relationship to PermissionSet works correctly +- [ ] System roles cannot be deleted +- [ ] Code interface works for all actions +- [ ] Cache invalidation callback registered (implementation in #6) +- [ ] All resource tests pass +- [ ] Resource added to Authorization domain + +--- + +### Issue #4: PermissionSetResource Ash Resource + +**Size:** M (2 days) +**Dependencies:** #2 +**Can work in parallel:** After #2, parallel with #3 +**Assignable to:** Backend Developer + +#### Description + +Create resource for managing resource-level permissions with unique constraints and cache invalidation. + +#### Tasks + +1. Create `lib/mv/authorization/permission_set_resource.ex` +2. Define attributes (resource_name, action, scope, field_name, granted) +3. Define `belongs_to` relationship to PermissionSet +4. Define actions (read, create, update, destroy) +5. Add unique constraint validation +6. Add cache invalidation on changes +7. Add code_interface +8. Add resource to Authorization domain + +#### Test Strategy (TDD) + +```elixir +# test/mv/authorization/permission_set_resource_test.exs +defmodule Mv.Authorization.PermissionSetResourceTest do + use Mv.DataCase, async: true + + describe "create_permission_set_resource/1" do + test "creates permission for resource action" do + ps = create_permission_set() + + attrs = %{ + permission_set_id: ps.id, + resource_name: "Member", + action: "read", + scope: "all", + field_name: nil, + granted: true + } + + assert {:ok, psr} = Mv.Authorization.create_permission_set_resource(attrs) + assert psr.resource_name == "Member" + assert psr.action == "read" + assert psr.scope == "all" + assert psr.granted == true + end + + test "requires permission_set_id" do + attrs = %{resource_name: "Member", action: "read"} + + assert {:error, error} = Mv.Authorization.create_permission_set_resource(attrs) + assert error.errors |> Enum.any?(fn e -> e.field == :permission_set_id end) + end + + test "requires resource_name" do + ps = create_permission_set() + attrs = %{permission_set_id: ps.id, action: "read"} + + assert {:error, error} = Mv.Authorization.create_permission_set_resource(attrs) + assert error.errors |> Enum.any?(fn e -> e.field == :resource_name end) + end + + test "requires action" do + ps = create_permission_set() + attrs = %{permission_set_id: ps.id, resource_name: "Member"} + + assert {:error, error} = Mv.Authorization.create_permission_set_resource(attrs) + assert error.errors |> Enum.any?(fn e -> e.field == :action end) + end + + test "defaults granted to false" do + ps = create_permission_set() + attrs = %{ + permission_set_id: ps.id, + resource_name: "Member", + action: "read" + } + + {:ok, psr} = Mv.Authorization.create_permission_set_resource(attrs) + assert psr.granted == false + end + + test "allows field_name to be null (Phase 1)" do + ps = create_permission_set() + attrs = %{ + permission_set_id: ps.id, + resource_name: "Member", + action: "read", + field_name: nil, + granted: true + } + + {:ok, psr} = Mv.Authorization.create_permission_set_resource(attrs) + assert psr.field_name == nil + end + + test "prevents duplicate permissions" do + ps = create_permission_set() + attrs = %{ + permission_set_id: ps.id, + resource_name: "Member", + action: "read", + scope: "all", + field_name: nil, + granted: true + } + + {:ok, _} = Mv.Authorization.create_permission_set_resource(attrs) + + # Try to create duplicate + assert {:error, error} = Mv.Authorization.create_permission_set_resource(attrs) + assert error.errors + |> Enum.any?(fn e -> String.contains?(to_string(e.message), "unique") end) + end + + test "allows same resource+action with different scope" do + ps = create_permission_set() + + {:ok, _} = Mv.Authorization.create_permission_set_resource(%{ + permission_set_id: ps.id, + resource_name: "Member", + action: "read", + scope: "all", + granted: true + }) + + # Different scope - should succeed + {:ok, psr2} = Mv.Authorization.create_permission_set_resource(%{ + permission_set_id: ps.id, + resource_name: "Member", + action: "read", + scope: "linked", + granted: true + }) + + assert psr2.scope == "linked" + end + end + + describe "list_permission_set_resources/1" do + test "filters by permission_set_id" do + ps1 = create_permission_set() + ps2 = create_permission_set() + + create_psr(%{permission_set_id: ps1.id, resource_name: "Member"}) + create_psr(%{permission_set_id: ps2.id, resource_name: "User"}) + + psrs = Mv.Authorization.list_permission_set_resources(permission_set_id: ps1.id) + + assert length(psrs) == 1 + assert List.first(psrs).resource_name == "Member" + end + + test "filters by resource_name" do + ps = create_permission_set() + + create_psr(%{permission_set_id: ps.id, resource_name: "Member"}) + create_psr(%{permission_set_id: ps.id, resource_name: "User"}) + + psrs = Mv.Authorization.list_permission_set_resources(resource_name: "Member") + + assert length(psrs) == 1 + end + end + + describe "update_permission_set_resource/2" do + test "updates granted status" do + ps = create_permission_set() + {:ok, psr} = create_psr(%{ + permission_set_id: ps.id, + resource_name: "Member", + granted: false + }) + + {:ok, updated} = Mv.Authorization.update_permission_set_resource(psr, %{ + granted: true + }) + + assert updated.granted == true + end + + test "invalidates cache for all users with this permission set" do + # TODO: Full implementation in Issue #6 + ps = create_permission_set() + {:ok, psr} = create_psr(%{ + permission_set_id: ps.id, + resource_name: "Member" + }) + + {:ok, _} = Mv.Authorization.update_permission_set_resource(psr, %{granted: true}) + + # Cache invalidation assertions will be added in Issue #6 + end + end + + # Helper functions + defp create_permission_set do + {:ok, ps} = Mv.Authorization.create_permission_set(%{ + name: "ps_#{System.unique_integer()}", + is_system: false + }) + ps + end + + defp create_psr(attrs) do + default = %{ + resource_name: "Resource#{System.unique_integer()}", + action: "read", + granted: false + } + Mv.Authorization.create_permission_set_resource(Map.merge(default, attrs)) + end +end +``` + +#### Definition of Done + +- [ ] PermissionSetResource created with all attributes +- [ ] Relationship to PermissionSet works +- [ ] Unique constraints enforced correctly +- [ ] Cache invalidation callback registered +- [ ] All CRUD actions work +- [ ] Code interface implemented +- [ ] All tests pass +- [ ] Resource added to domain + +--- + +### Issue #5: PermissionSetPage Ash Resource + +**Size:** S (1 day) +**Dependencies:** #2 +**Can work in parallel:** After #2, parallel with #3 and #4 +**Assignable to:** Backend Developer + +#### Description + +Create resource for managing page-level permissions. + +#### Tasks + +1. Create `lib/mv/authorization/permission_set_page.ex` +2. Define attributes (page_path) +3. Define `belongs_to` relationship to PermissionSet +4. Define actions (read, create, update, destroy) +5. Add unique constraint validation +6. Add code_interface +7. Add resource to Authorization domain + +#### Test Strategy (TDD) + +```elixir +# test/mv/authorization/permission_set_page_test.exs +defmodule Mv.Authorization.PermissionSetPageTest do + use Mv.DataCase, async: true + + describe "create_permission_set_page/1" do + test "creates page permission" do + ps = create_permission_set() + + attrs = %{ + permission_set_id: ps.id, + page_path: "/members" + } + + assert {:ok, psp} = Mv.Authorization.create_permission_set_page(attrs) + assert psp.page_path == "/members" + assert psp.permission_set_id == ps.id + end + + test "requires permission_set_id" do + attrs = %{page_path: "/members"} + + assert {:error, error} = Mv.Authorization.create_permission_set_page(attrs) + assert error.errors |> Enum.any?(fn e -> e.field == :permission_set_id end) + end + + test "requires page_path" do + ps = create_permission_set() + attrs = %{permission_set_id: ps.id} + + assert {:error, error} = Mv.Authorization.create_permission_set_page(attrs) + assert error.errors |> Enum.any?(fn e -> e.field == :page_path end) + end + + test "prevents duplicate page permissions" do + ps = create_permission_set() + attrs = %{ + permission_set_id: ps.id, + page_path: "/members" + } + + {:ok, _} = Mv.Authorization.create_permission_set_page(attrs) + + # Try duplicate + assert {:error, error} = Mv.Authorization.create_permission_set_page(attrs) + assert error.errors + |> Enum.any?(fn e -> String.contains?(to_string(e.message), "unique") end) + end + + test "allows same page_path for different permission sets" do + ps1 = create_permission_set() + ps2 = create_permission_set() + + {:ok, _} = Mv.Authorization.create_permission_set_page(%{ + permission_set_id: ps1.id, + page_path: "/members" + }) + + {:ok, psp2} = Mv.Authorization.create_permission_set_page(%{ + permission_set_id: ps2.id, + page_path: "/members" + }) + + assert psp2.page_path == "/members" + assert psp2.permission_set_id == ps2.id + end + + test "supports dynamic page paths" do + ps = create_permission_set() + + {:ok, psp} = Mv.Authorization.create_permission_set_page(%{ + permission_set_id: ps.id, + page_path: "/members/:id/edit" + }) + + assert psp.page_path == "/members/:id/edit" + end + end + + describe "list_permission_set_pages/1" do + test "filters by permission_set_id" do + ps1 = create_permission_set() + ps2 = create_permission_set() + + create_psp(%{permission_set_id: ps1.id, page_path: "/members"}) + create_psp(%{permission_set_id: ps2.id, page_path: "/users"}) + + psps = Mv.Authorization.list_permission_set_pages(permission_set_id: ps1.id) + + assert length(psps) == 1 + assert List.first(psps).page_path == "/members" + end + end + + describe "destroy_permission_set_page/1" do + test "destroys page permission" do + ps = create_permission_set() + {:ok, psp} = create_psp(%{ + permission_set_id: ps.id, + page_path: "/test" + }) + + assert {:ok, _} = Mv.Authorization.destroy_permission_set_page(psp) + assert {:error, _} = Mv.Authorization.get_permission_set_page(psp.id) + end + end + + # Helper functions + defp create_permission_set do + {:ok, ps} = Mv.Authorization.create_permission_set(%{ + name: "ps_#{System.unique_integer()}", + is_system: false + }) + ps + end + + defp create_psp(attrs) do + default = %{page_path: "/page_#{System.unique_integer()}"} + Mv.Authorization.create_permission_set_page(Map.merge(default, attrs)) + end +end +``` + +#### Definition of Done + +- [ ] PermissionSetPage resource created +- [ ] Relationship to PermissionSet works +- [ ] Unique constraints enforced +- [ ] All CRUD actions work +- [ ] Code interface implemented +- [ ] All tests pass +- [ ] Resource added to domain + +--- + +### Issue #6: Permission Cache (ETS) + +**Size:** M (2 days) +**Dependencies:** #2, #3 +**Can work in parallel:** After #2 and #3, parallel with #4 and #5 +**Assignable to:** Backend Developer + +#### Description + +Implement ETS-based permission cache for performance optimization. + +#### Tasks + +1. Create `lib/mv/authorization/permission_cache.ex` +2. Implement GenServer for cache management +3. Create ETS table with appropriate configuration +4. Add functions: `get_permission_set/1`, `put_permission_set/2` +5. Add functions: `get_page_permission/2`, `put_page_permission/3` +6. Add invalidation functions: `invalidate_user/1`, `invalidate_all/0` +7. Add to application supervision tree (`lib/mv/application.ex`) +8. Update Issue #3 to use cache invalidation + +#### Test Strategy (TDD) + +```elixir +# test/mv/authorization/permission_cache_test.exs +defmodule Mv.Authorization.PermissionCacheTest do + use ExUnit.Case, async: false + + alias Mv.Authorization.PermissionCache + + setup do + # Start cache GenServer + start_supervised!(PermissionCache) + :ok + end + + describe "permission_set cache" do + test "stores and retrieves permission sets" do + ps = %{id: Ecto.UUID.generate(), name: "test"} + user_id = Ecto.UUID.generate() + + :ok = PermissionCache.put_permission_set(user_id, ps) + assert {:ok, ^ps} = PermissionCache.get_permission_set(user_id) + end + + test "returns :miss for uncached users" do + user_id = Ecto.UUID.generate() + assert :miss = PermissionCache.get_permission_set(user_id) + end + + test "can update cached permission set" do + user_id = Ecto.UUID.generate() + ps1 = %{id: Ecto.UUID.generate(), name: "first"} + ps2 = %{id: Ecto.UUID.generate(), name: "second"} + + PermissionCache.put_permission_set(user_id, ps1) + PermissionCache.put_permission_set(user_id, ps2) + + assert {:ok, ^ps2} = PermissionCache.get_permission_set(user_id) + end + end + + describe "page_permission cache" do + test "stores and retrieves page permissions" do + user_id = Ecto.UUID.generate() + page_path = "/members" + + :ok = PermissionCache.put_page_permission(user_id, page_path, true) + assert {:ok, true} = PermissionCache.get_page_permission(user_id, page_path) + end + + test "returns :miss for uncached page permissions" do + user_id = Ecto.UUID.generate() + assert :miss = PermissionCache.get_page_permission(user_id, "/members") + end + + test "can cache multiple pages for same user" do + user_id = Ecto.UUID.generate() + + PermissionCache.put_page_permission(user_id, "/members", true) + PermissionCache.put_page_permission(user_id, "/users", false) + + assert {:ok, true} = PermissionCache.get_page_permission(user_id, "/members") + assert {:ok, false} = PermissionCache.get_page_permission(user_id, "/users") + end + end + + describe "invalidate_user/1" do + test "removes all cache entries for user" do + user_id = Ecto.UUID.generate() + ps = %{id: Ecto.UUID.generate(), name: "test"} + + PermissionCache.put_permission_set(user_id, ps) + PermissionCache.put_page_permission(user_id, "/members", true) + PermissionCache.put_page_permission(user_id, "/users", false) + + # All cached + assert {:ok, _} = PermissionCache.get_permission_set(user_id) + assert {:ok, _} = PermissionCache.get_page_permission(user_id, "/members") + assert {:ok, _} = PermissionCache.get_page_permission(user_id, "/users") + + # Invalidate + :ok = PermissionCache.invalidate_user(user_id) + + # All should be miss + assert :miss = PermissionCache.get_permission_set(user_id) + assert :miss = PermissionCache.get_page_permission(user_id, "/members") + assert :miss = PermissionCache.get_page_permission(user_id, "/users") + end + + test "only invalidates specified user" do + user1_id = Ecto.UUID.generate() + user2_id = Ecto.UUID.generate() + + PermissionCache.put_permission_set(user1_id, %{id: 1}) + PermissionCache.put_permission_set(user2_id, %{id: 2}) + + PermissionCache.invalidate_user(user1_id) + + assert :miss = PermissionCache.get_permission_set(user1_id) + assert {:ok, %{id: 2}} = PermissionCache.get_permission_set(user2_id) + end + end + + describe "invalidate_all/0" do + test "removes all cache entries" do + user1 = Ecto.UUID.generate() + user2 = Ecto.UUID.generate() + + PermissionCache.put_permission_set(user1, %{id: 1}) + PermissionCache.put_permission_set(user2, %{id: 2}) + PermissionCache.put_page_permission(user1, "/members", true) + + :ok = PermissionCache.invalidate_all() + + assert :miss = PermissionCache.get_permission_set(user1) + assert :miss = PermissionCache.get_permission_set(user2) + assert :miss = PermissionCache.get_page_permission(user1, "/members") + end + end + + describe "cache persistence" do + test "cache survives across requests" do + user_id = Ecto.UUID.generate() + ps = %{id: Ecto.UUID.generate(), name: "test"} + + PermissionCache.put_permission_set(user_id, ps) + + # Simulate multiple requests + for _ <- 1..10 do + assert {:ok, ^ps} = PermissionCache.get_permission_set(user_id) + end + end + + test "concurrent reads work correctly" do + user_id = Ecto.UUID.generate() + ps = %{id: Ecto.UUID.generate(), name: "test"} + + PermissionCache.put_permission_set(user_id, ps) + + # Concurrent reads + tasks = for _ <- 1..100 do + Task.async(fn -> + PermissionCache.get_permission_set(user_id) + end) + end + + results = Task.await_many(tasks) + + # All should succeed + assert Enum.all?(results, fn result -> result == {:ok, ps} end) + end + end +end +``` + +#### Definition of Done + +- [ ] ETS cache GenServer implemented +- [ ] All cache operations work correctly +- [ ] Invalidation works for single user and all users +- [ ] Cache survives across requests +- [ ] Concurrent access works safely +- [ ] Added to supervision tree +- [ ] Issue #3 updated to invalidate cache on role update +- [ ] All cache tests pass + +--- + +## Sprint 2: Policy System (Weeks 2-3) + +### Issue #7: Custom Policy Check - HasResourcePermission + +**Size:** L (3 days) +**Dependencies:** #2, #3, #4, #6 +**Can work in parallel:** No (needs cache and resources) +**Assignable to:** Backend Developer + +#### Description + +Implement custom Ash policy check that queries permission database and evaluates scope. + +#### Tasks + +1. Create `lib/mv/authorization/checks/has_resource_permission.ex` +2. Implement Ash.Policy.Check behavior +3. Implement `match?/3` function +4. Implement scope evaluation (own, linked, all) +5. Integrate with permission cache +6. Handle all resource types (Member, User, Property, PropertyType) +7. Add comprehensive logging for debugging + +#### Test Strategy (TDD) + +```elixir +# test/mv/authorization/checks/has_resource_permission_test.exs +defmodule Mv.Authorization.Checks.HasResourcePermissionTest do + use Mv.DataCase, async: false + + alias Mv.Authorization.Checks.HasResourcePermission + + describe "match? with granted=true" do + test "authorizes when permission exists with granted=true and scope=all" do + user = create_user_with_permission("Member", "read", "all", true) + context = build_context(Mv.Membership.Member, :read, user) + + assert :authorized = HasResourcePermission.match?(user, context, []) + end + + test "authorizes for different actions" do + user = create_user_with_permission("Member", "update", "all", true) + context = build_context(Mv.Membership.Member, :update, user) + + assert :authorized = HasResourcePermission.match?(user, context, []) + end + + test "authorizes for different resources" do + user = create_user_with_permission("User", "read", "all", true) + context = build_context(Mv.Accounts.User, :read, user) + + assert :authorized = HasResourcePermission.match?(user, context, []) + end + end + + describe "match? with granted=false" do + test "forbids when permission exists with granted=false" do + user = create_user_with_permission("Member", "read", "all", false) + context = build_context(Mv.Membership.Member, :read, user) + + assert :forbidden = HasResourcePermission.match?(user, context, []) + end + end + + describe "match? with no permission" do + test "forbids when no permission exists" do + user = create_user_without_permissions() + context = build_context(Mv.Membership.Member, :read, user) + + assert :forbidden = HasResourcePermission.match?(user, context, []) + end + end + + describe "scope='own'" do + test "returns filter for scope='own'" do + user = create_user_with_permission("User", "read", "own", true) + context = build_context(Mv.Accounts.User, :read, user) + + result = HasResourcePermission.match?(user, context, []) + + assert {:filter, filter_expr} = result + # Verify filter contains user.id check + end + end + + describe "scope='linked'" do + test "returns filter for scope='linked' on Member" do + user = create_user_with_permission("Member", "read", "linked", true) + context = build_context(Mv.Membership.Member, :read, user) + + result = HasResourcePermission.match?(user, context, []) + + assert {:filter, filter_expr} = result + # Verify filter contains user_id check + end + + test "returns filter for scope='linked' on Property" do + user = create_user_with_permission("Property", "read", "linked", true) + context = build_context(Mv.Membership.Property, :read, user) + + result = HasResourcePermission.match?(user, context, []) + + assert {:filter, filter_expr} = result + # Verify filter contains member.user_id check + end + end + + describe "cache integration" do + test "uses cache when available" do + user = create_user_with_permission("Member", "read", "all", true) + context = build_context(Mv.Membership.Member, :read, user) + + # First call - cache miss + assert :authorized = HasResourcePermission.match?(user, context, []) + + # Verify cache was populated + assert {:ok, _} = PermissionCache.get_permission_set(user.id) + + # Second call - cache hit (should be faster) + assert :authorized = HasResourcePermission.match?(user, context, []) + end + + test "loads from database on cache miss" do + user = create_user_with_permission("Member", "read", "all", true) + context = build_context(Mv.Membership.Member, :read, user) + + # Clear cache + PermissionCache.invalidate_user(user.id) + + # Should still work by loading from DB + assert :authorized = HasResourcePermission.match?(user, context, []) + end + end + + describe "nil actor" do + test "forbids when actor is nil" do + context = build_context(Mv.Membership.Member, :read, nil) + + assert :forbidden = HasResourcePermission.match?(nil, context, []) + end + end + + # Helper functions + defp create_user_with_permission(resource_name, action, scope, granted) do + ps = create_permission_set() + create_permission_set_resource(%{ + permission_set_id: ps.id, + resource_name: resource_name, + action: action, + scope: scope, + granted: granted + }) + + role = create_role(%{permission_set_id: ps.id}) + create_user(%{role_id: role.id}) + end + + defp create_user_without_permissions do + ps = create_permission_set() + role = create_role(%{permission_set_id: ps.id}) + create_user(%{role_id: role.id}) + end + + defp build_context(resource, action_name, actor) do + %{ + resource: resource, + action: %{name: action_name}, + actor: actor + } + end +end +``` + +#### Definition of Done + +- [ ] Policy check fully implemented +- [ ] All scope types handled correctly +- [ ] Cache integration works +- [ ] Handles nil actor gracefully +- [ ] Works for all resource types +- [ ] Logging added for debugging +- [ ] All policy check tests pass + +--- + +**Note:** Due to length constraints, the remaining issues (#8-#16) follow the same detailed format with: +- Size, Dependencies, Parallel Work info +- Description +- Tasks list +- Complete TDD test strategy +- Definition of Done + +The full document continues with Sprint 2 (Issues #8-#12), Sprint 3 (Issues #13-#14), and Sprint 4 (Issues #15-#16). + +--- + +## Parallel Work Opportunities + +### After Issue #1 (DB Schema) + +Can work in parallel: +- Issue #2 (PermissionSet) +- Issue #3 (Role) - after #2 completes +- Issue #4 (PermissionSetResource) - after #2 completes +- Issue #5 (PermissionSetPage) - after #2 completes + +### After Issue #2-#6 (Resources & Cache) + +Can work in parallel: +- Issue #7 (Policy Check) - needs #2, #3, #4, #6 +- Then after #7: + - Issue #8 (Member Policies) + - Issue #9 (User Policies) + - Issue #10 (Property Policies) + - Issue #11 (PropertyType Policies) + - Issue #12 (Page Permission Plug) + +### After Issue #8-#12 (Policies) + +Can work in parallel: +- Issue #13 (Email Validation) +- Issue #14 (Seeds) + +### Final Phase (Sequential) + +- Issue #15 (UI Authorization Helper) - needs #6, #13, #14 +- Issue #16 (Admin UI) - needs #15 +- Issue #17 (UI Auth in LiveViews) - needs #15, #16 +- Issue #18 (Integration Tests) - needs everything + +--- + +## Sprint 4: UI & Integration (Week 4) + +### Issue #15: UI Authorization Helper Module + +**Size:** M (2-3 days) +**Dependencies:** #6 (Cache), #13 (Email Validation), #14 (Seeds) +**Can work in parallel:** No (needs cache and seeds) +**Assignable to:** Backend Developer + Frontend Developer + +#### Description + +Create helper module for UI-level authorization checks in LiveView templates and modules. + +#### Tasks + +1. Create `lib/mv_web/authorization.ex` +2. Implement `can?/3` for resource-level permissions (atom resource) +3. Implement `can?/3` for record-level permissions (struct) +4. Implement `can_access_page?/2` for page permissions +5. Add private helpers for cache integration +6. Add scope checking for records (own, linked, all) +7. Add comprehensive documentation and examples + +#### Test Strategy (TDD) + +```elixir +# test/mv_web/authorization_test.exs +defmodule MvWeb.AuthorizationTest do + use Mv.DataCase, async: false + + import MvWeb.Authorization + + describe "can?/3 with resource atom" do + test "returns true when user has permission" do + user = create_user_with_permission("Member", "read", "all", true) + + assert can?(user, :read, Mv.Membership.Member) == true + end + + test "returns false when user lacks permission" do + user = create_user_without_permission() + + assert can?(user, :read, Mv.Membership.Member) == false + end + + test "returns false for nil user" do + assert can?(nil, :read, Mv.Membership.Member) == false + end + + test "uses cache when available" do + user = create_user_with_permission("Member", "read", "all", true) + + # First call - cache miss + assert can?(user, :read, Mv.Membership.Member) == true + + # Verify cache was populated + assert {:ok, _} = Mv.Authorization.PermissionCache.get_permission_set(user.id) + + # Second call - cache hit + assert can?(user, :read, Mv.Membership.Member) == true + end + end + + describe "can?/3 with record struct and scope='all'" do + test "returns true for any record when user has scope='all'" do + user = create_user_with_permission("Member", "update", "all", true) + member = create_member() + + assert can?(user, :update, member) == true + end + end + + describe "can?/3 with record struct and scope='own'" do + test "returns true for own user record" do + user = create_user() + + # Users always have own data access + assert can?(user, :read, user) == true + end + + test "returns false for other user record" do + user1 = create_user_with_role("Mitglied") + user2 = create_user_with_role("Mitglied") + + assert can?(user1, :read, user2) == false + end + end + + describe "can?/3 with record struct and scope='linked'" do + test "returns true for linked member" do + user = create_user() + member = create_member_linked_to_user(user) + + assert can?(user, :read, member) == true + end + + test "returns false for unlinked member" do + user = create_user_with_role("Mitglied") + member = create_member() # Not linked to user + + assert can?(user, :read, member) == false + end + + test "returns true for property of linked member" do + user = create_user() + member = create_member_linked_to_user(user) + property = create_property(member) + + assert can?(user, :read, property) == true + end + + test "returns false for property of unlinked member" do + user = create_user_with_role("Mitglied") + member = create_member() + property = create_property(member) + + assert can?(user, :read, property) == false + end + end + + describe "can_access_page?/2" do + test "returns true when user has page permission" do + user = create_user_with_page_permission("/members") + + assert can_access_page?(user, "/members") == true + end + + test "returns false when user lacks page permission" do + user = create_user_with_role("Mitglied") + + assert can_access_page?(user, "/users") == false + end + + test "returns false for nil user" do + assert can_access_page?(nil, "/members") == false + end + + test "caches page permissions" do + user = create_user_with_page_permission("/members") + + # First call + assert can_access_page?(user, "/members") == true + + # Verify cache + assert {:ok, true} = + Mv.Authorization.PermissionCache.get_page_permission(user.id, "/members") + + # Second call uses cache + assert can_access_page?(user, "/members") == true + end + end +end +``` + +#### Definition of Done + +- [ ] `MvWeb.Authorization` module created +- [ ] All `can?/3` variants implemented +- [ ] `can_access_page?/2` implemented +- [ ] Scope checking works correctly (own, linked, all) +- [ ] Cache integration works +- [ ] All helper tests pass +- [ ] Documentation complete with examples + +--- + +### Issue #16: Admin UI for Role Management + +**Size:** L (3-4 days) +**Dependencies:** #3, #8, #9, #14, #15 +**Can work in parallel:** No (needs everything else) +**Assignable to:** Frontend Developer + Backend Developer + +#### Description + +Create LiveView pages for managing roles and assigning them to users. Uses UI Authorization helpers from #15. + +#### Tasks + +1. Create `RoleLive.Index` for listing roles +2. Create `RoleLive.Form` for creating/editing roles +3. Create `UserLive` extension for role assignment +4. Add permission checks using `can?` helper (only admin can access) +5. Show which permission set each role uses +6. Allow changing role's permission set +7. Show users assigned to each role +8. Implement UI authorization for buttons/links + +#### Test Strategy (TDD) + +```elixir +# test/mv_web/role_live/index_test.exs +defmodule MvWeb.RoleLive.IndexTest do + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + describe "RoleLive.Index access control" do + test "admin can access role management page", %{conn: conn} do + admin = create_user_with_role("Admin") + conn = log_in_user(conn, admin) + + {:ok, view, html} = live(conn, ~p"/admin/roles") + + assert html =~ "Roles" + end + + test "non-admin cannot access role management page", %{conn: conn} do + user = create_user_with_role("Mitglied") + conn = log_in_user(conn, user) + + {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/admin/roles") + end + end + + describe "RoleLive.Index display" do + test "displays all roles", %{conn: conn} do + admin = create_user_with_role("Admin") + conn = log_in_user(conn, admin) + + {:ok, view, html} = live(conn, ~p"/admin/roles") + + assert html =~ "Mitglied" + assert html =~ "Admin" + assert html =~ "Vorstand" + end + + test "system roles cannot be deleted", %{conn: conn} do + admin = create_user_with_role("Admin") + conn = log_in_user(conn, admin) + mitglied = get_role_by_name("Mitglied") + + {:ok, view, _html} = live(conn, ~p"/admin/roles") + + # Delete button should not exist for system roles + refute has_element?(view, "#role-#{mitglied.id} .delete-button") + end + end + + describe "RoleLive role creation" do + test "can create new role", %{conn: conn} do + admin = create_user_with_role("Admin") + conn = log_in_user(conn, admin) + + {:ok, view, _html} = live(conn, ~p"/admin/roles") + + view + |> element("a", "New Role") + |> render_click() + + view + |> form("#role-form", role: %{ + name: "Test Role", + description: "Test", + permission_set_id: get_permission_set_id("read_only") + }) + |> render_submit() + + assert_patch(view, ~p"/admin/roles") + assert render(view) =~ "Test Role" + end + end +end + +# test/mv_web/user_live/index_test.exs (extension) +describe "UserLive role assignment" do + test "admin can change user's role", %{conn: conn} do + admin = create_user_with_role("Admin") + user = create_user_with_role("Mitglied") + conn = log_in_user(conn, admin) + + {:ok, view, _html} = live(conn, ~p"/users") + + view + |> element("#user-#{user.id} .role-selector") + |> render_change(%{role_id: get_role_id("Vorstand")}) + + updated_user = Ash.reload!(user) + assert updated_user.role_id == get_role_id("Vorstand") + end + + test "invalidates cache when role changed", %{conn: conn} do + admin = create_user_with_role("Admin") + user = create_user_with_role("Mitglied") + + # Populate cache + Mv.Authorization.PermissionCache.put_permission_set(user.id, %{}) + + conn = log_in_user(conn, admin) + {:ok, view, _html} = live(conn, ~p"/users") + + view + |> element("#user-#{user.id} .role-selector") + |> render_change(%{role_id: get_role_id("Vorstand")}) + + # Cache should be invalidated + assert :miss = Mv.Authorization.PermissionCache.get_permission_set(user.id) + end +end +``` + +#### Definition of Done + +- [ ] Role management UI created +- [ ] Only admin can access (enforced with `can_access_page?`) +- [ ] Can create/edit/delete roles +- [ ] System roles cannot be deleted (UI hidden with `can?`) +- [ ] Can assign roles to users +- [ ] Cache invalidation on changes +- [ ] All UI tests pass +- [ ] Uses `can?` and `can_access_page?` helpers throughout + +--- + +### Issue #17: Apply UI Authorization to Existing LiveViews + +**Size:** L (3-4 days) +**Dependencies:** #15, #16 +**Can work in parallel:** No (needs UI helpers and Admin UI as example) +**Assignable to:** Frontend Developer + +#### Description + +Update all existing LiveView templates and modules to use UI authorization helpers, hiding links and buttons based on permissions. + +#### Tasks + +1. Update `lib/mv_web/components/layouts/navbar.html.heex` with `can_access_page?` +2. Update `MemberLive.Index` - hide "New Member" button, Edit/Delete per row +3. Update `MemberLive.Show` - hide Edit/Delete buttons +4. Update `UserLive.Index` - show only if admin +5. Update `PropertyLive.Index` - check permissions +6. Update `PropertyTypeLive.Index` - show Edit/Delete only for admin +7. Import `MvWeb.Authorization` in all relevant LiveView modules +8. Add permission checks in `mount` functions where appropriate + +#### Test Strategy (TDD) + +```elixir +# test/mv_web/member_live/index_test.exs +defmodule MvWeb.MemberLive.IndexTest do + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + describe "UI authorization for Mitglied role" do + test "does not show 'New Member' button", %{conn: conn} do + user = create_user_with_role("Mitglied") + member = create_member_linked_to_user(user) + conn = log_in_user(conn, user) + + {:ok, view, html} = live(conn, ~p"/members") + + refute html =~ "New Member" + refute has_element?(view, "a", "New Member") + end + + test "shows only 'Show' button for own member", %{conn: conn} do + user = create_user_with_role("Mitglied") + member = create_member_linked_to_user(user) + conn = log_in_user(conn, user) + + {:ok, view, html} = live(conn, ~p"/members") + + # Show button should exist + assert has_element?(view, "a[href='/members/#{member.id}']", "Show") + + # Edit and Delete buttons should NOT exist + refute has_element?(view, "a[href='/members/#{member.id}/edit']", "Edit") + refute has_element?(view, "button[phx-click='delete']", "Delete") + end + end + + describe "UI authorization for Kassenwart role" do + test "shows 'New Member' button", %{conn: conn} do + user = create_user_with_role("Kassenwart") + conn = log_in_user(conn, user) + + {:ok, view, html} = live(conn, ~p"/members") + + assert html =~ "New Member" + assert has_element?(view, "a", "New Member") + end + + test "shows Edit and Delete buttons for all members", %{conn: conn} do + user = create_user_with_role("Kassenwart") + member1 = create_member() + member2 = create_member() + conn = log_in_user(conn, user) + + {:ok, view, _html} = live(conn, ~p"/members") + + # Both members should have Edit and Delete buttons + assert has_element?(view, "a[href='/members/#{member1.id}/edit']", "Edit") + assert has_element?(view, "a[href='/members/#{member2.id}/edit']", "Edit") + + assert has_element?(view, ~s([phx-click="delete"][phx-value-id="#{member1.id}"])) + assert has_element?(view, ~s([phx-click="delete"][phx-value-id="#{member2.id}"])) + end + end + + describe "UI authorization for Admin role" do + test "shows all action buttons", %{conn: conn} do + admin = create_user_with_role("Admin") + member = create_member() + conn = log_in_user(conn, admin) + + {:ok, view, html} = live(conn, ~p"/members") + + assert html =~ "New Member" + assert has_element?(view, "a[href='/members/#{member.id}/edit']", "Edit") + assert has_element?(view, ~s([phx-click="delete"][phx-value-id="#{member.id}"])) + end + end +end + +# test/mv_web/components/layouts/navbar_test.exs +defmodule MvWeb.Layouts.NavbarTest do + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + describe "navigation links for Mitglied role" do + test "does not show admin links", %{conn: conn} do + user = create_user_with_role("Mitglied") + conn = log_in_user(conn, user) + + {:ok, view, html} = live(conn, ~p"/") + + refute html =~ "Users" + refute html =~ "Custom Fields" + refute html =~ "Roles" + end + end + + describe "navigation links for Admin role" do + test "shows all navigation links", %{conn: conn} do + admin = create_user_with_role("Admin") + conn = log_in_user(conn, admin) + + {:ok, view, html} = live(conn, ~p"/") + + assert html =~ "Members" + assert html =~ "Users" + assert html =~ "Custom Fields" + assert html =~ "Roles" + end + end +end +``` + +#### Definition of Done + +- [ ] Navbar updated with `can_access_page?` checks +- [ ] All MemberLive pages updated +- [ ] All UserLive pages updated +- [ ] All PropertyLive pages updated +- [ ] All PropertyTypeLive pages updated +- [ ] All LiveView modules import `MvWeb.Authorization` +- [ ] All UI authorization tests pass +- [ ] No unauthorized buttons/links visible + +--- + +### Issue #18: Integration Tests - Complete Scenarios + +**Size:** L (3 days) +**Dependencies:** All previous issues +**Can work in parallel:** No (needs everything) +**Assignable to:** Backend Developer + QA + +#### Description + +Write comprehensive integration tests for complete user journeys across all roles. + +#### Test Strategy + +```elixir +# test/mv/authorization/integration_test.exs +defmodule Mv.Authorization.IntegrationTest do + use Mv.DataCase, async: false + use MvWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + import MvWeb.Authorization + + describe "Complete Mitglied user journey" do + test "can only access own data" do + # Setup + member = create_member() + user = create_user_linked_to_member(member) + assign_role(user, "Mitglied") + + # Can read own member + {:ok, fetched} = Ash.get(Mv.Membership.Member, member.id, actor: user) + assert fetched.id == member.id + + # Can update own member + {:ok, updated} = Ash.update(member, %{first_name: "New"}, actor: user) + assert updated.first_name == "New" + + # Cannot read other members + other_member = create_member() + {:ok, members} = Ash.read(Mv.Membership.Member, actor: user) + assert length(members) == 1 + + # Can always update own credentials + {:ok, updated_user} = Ash.update(user, %{email: "new@example.com"}, actor: user) + assert updated_user.email == "new@example.com" + + # UI: No "New Member" button + assert can?(user, :create, Mv.Membership.Member) == false + + # UI: No "Users" link + assert can_access_page?(user, "/users") == false + end + end + + describe "Complete Kassenwart user journey" do + test "can manage all members but not users" do + user = create_user_with_role("Kassenwart") + member1 = create_member() + member2 = create_member() + + # Can read all members + {:ok, members} = Ash.read(Mv.Membership.Member, actor: user) + assert length(members) == 2 + + # Can update members + {:ok, updated} = Ash.update(member1, %{first_name: "Updated"}, actor: user) + assert updated.first_name == "Updated" + + # Can create members + {:ok, new_member} = Mv.Membership.create_member( + %{first_name: "New", last_name: "Member", email: "new@example.com"}, + actor: user + ) + assert new_member.first_name == "New" + + # Cannot access users + assert {:error, %Ash.Error.Forbidden{}} = + Ash.read(Mv.Accounts.User, actor: user) + + # UI: Has "New Member" button + assert can?(user, :create, Mv.Membership.Member) == true + + # UI: Can access edit pages + assert can_access_page?(user, "/members/:id/edit") == true + + # UI: Cannot access users page + assert can_access_page?(user, "/users") == false + end + end + + describe "Complete Admin user journey" do + test "has full access to everything" do + admin = create_user_with_role("Admin") + user = create_user_with_role("Mitglied") + member = create_member() + + # Can manage all resources + {:ok, members} = Ash.read(Mv.Membership.Member, actor: admin) + {:ok, users} = Ash.read(Mv.Accounts.User, actor: admin) + + # Can update other users' credentials + {:ok, updated_user} = Ash.update( + user, + %{email: "admin-changed@example.com"}, + actor: admin + ) + assert updated_user.email == "admin-changed@example.com" + + # Can manage roles + {:ok, new_role} = Mv.Authorization.create_role( + %{name: "New Role", permission_set_id: get_permission_set_id("read_only")}, + actor: admin + ) + assert new_role.name == "New Role" + + # UI: Can access all pages + assert can_access_page?(admin, "/admin") == true + assert can_access_page?(admin, "/users") == true + assert can_access_page?(admin, "/admin/roles") == true + end + end + + describe "UI and Ash policy consistency" do + test "UI never shows action that Ash would forbid" do + # For each role, verify UI and Ash agree + roles = ["Mitglied", "Vorstand", "Kassenwart", "Buchhaltung", "Admin"] + + for role_name <- roles do + user = create_user_with_role(role_name) + + # Test Member actions + if can?(user, :create, Mv.Membership.Member) do + # If UI says yes, Ash should allow + assert {:ok, _} = Mv.Membership.create_member( + %{first_name: "Test", last_name: "User", email: "test@example.com"}, + actor: user + ) + else + # If UI says no, Ash should forbid + assert {:error, %Ash.Error.Forbidden{}} = + Mv.Membership.create_member( + %{first_name: "Test", last_name: "User", email: "test@example.com"}, + actor: user + ) + end + + # Test User access + if can_access_page?(user, "/users") do + # If UI shows link, Ash should allow read + assert {:ok, _} = Ash.read(Mv.Accounts.User, actor: user) + else + # If UI hides link, Ash should forbid or return empty + case Ash.read(Mv.Accounts.User, actor: user) do + {:error, %Ash.Error.Forbidden{}} -> assert true + {:ok, []} -> assert true # Filtered to nothing + {:ok, [%{id: id}]} -> assert id == user.id # Only own user + end + end + end + end + end + + describe "Cache invalidation flows" do + test "role change invalidates cache and updates UI permissions" do + user = create_user_with_role("Mitglied") + + # Mitglied cannot create members + assert can?(user, :create, Mv.Membership.Member) == false + + # Change to Kassenwart + assign_role(user, "Kassenwart") + + # Cache should be invalidated + assert :miss = Mv.Authorization.PermissionCache.get_permission_set(user.id) + + # Reload user + user = Ash.reload!(user) + + # Now can create members + assert can?(user, :create, Mv.Membership.Member) == true + end + end +end +``` + +#### Definition of Done + +- [ ] All user journeys tested (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin) +- [ ] All special cases covered (email validation, own credentials, linked members) +- [ ] UI and Ash policy consistency verified +- [ ] Cache behavior verified across all scenarios +- [ ] Cross-resource authorization works +- [ ] All integration tests pass +- [ ] Test coverage meets goals (>80%) + +--- + +## Summary + +### Overview + +**Total Issues:** 18 +**Estimated Duration:** 4-5 weeks +**Team Size:** 2-3 Backend Developers + 1 Frontend Developer + +### Parallelization Opportunities + +| Sprint | Max Parallel Issues | Sequential Issues | +|--------|---------------------|-------------------| +| Sprint 1 | 3 | 2 | +| Sprint 2 | 5 | 2 | +| Sprint 3 | 2 | 0 | +| Sprint 4 | 1 | 3 | + +### Test Coverage + +**Estimated Test Count:** 350+ tests + +| Test Type | Count | Coverage | +|-----------|-------|----------| +| Unit Tests | ~160 | Resource CRUD, Policy checks, Cache operations, UI helpers | +| Integration Tests | ~120 | Cross-resource authorization, Special cases, UI/Ash consistency | +| LiveView Tests | ~60 | Page permissions, UI interactions, Authorization display | +| E2E Tests | ~10 | Complete user journeys | + +### Risk Assessment + +| Risk | Probability | Impact | Mitigation | +|------|------------|--------|------------| +| Cache invalidation bugs | Medium | High | Comprehensive tests, manual testing | +| Policy order issues | Medium | High | Clear documentation, integration tests | +| Performance degradation | Low | Medium | Cache layer, performance tests | +| Scope filter errors | Medium | High | TDD approach, extensive testing | +| Breaking existing auth | Low | High | Feature flag, gradual rollout | + +--- + +## Data Migration + +### Existing Users + +All existing users will be assigned the "Mitglied" (Member) role by default: + +```sql +-- Migration: Set default role for existing users +-- This happens in Issue #14 seeds +UPDATE users +SET role_id = (SELECT id FROM roles WHERE name = 'Mitglied') +WHERE role_id IS NULL; +``` + +### Backward Compatibility + +**Phase 1 (This Implementation):** +- No existing authorization system to maintain +- Clean slate implementation +- All tests ensure new system works correctly + +**Phase 2 (Field-Level - Future):** +- Existing `permission_set_resources` with `field_name = NULL` continue to work +- No migration needed, just add new field-specific permissions +- Backward compatible by design + +### Rollback Plan + +If critical issues are discovered after deployment: + +1. **Database Rollback:** +```bash +# Rollback all authorization migrations +mix ecto.rollback --step 1 # Or specific migration +``` + +2. **Code Rollback:** +- Remove authorization policies from resources +- Comment out PermissionCache from supervision tree +- Remove page permission plug from router + +3. **Verification:** +- Test that existing functionality still works +- Verify no permission checks blocking access +- Check logs for errors + +--- + +## Document History + +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0 | 2025-11-10 | Development Team | Initial implementation plan | + +--- + +**Related Documents:** +- [Architecture Design](./roles-and-permissions-architecture.md) +- [Code Guidelines](../CODE_GUIDELINES.md) +- [Database Schema](./database-schema-readme.md) + +--- + +**End of Document** + From a19026e430aee354a5abd14d0eaf177f9fbf95d9 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 11 Nov 2025 15:57:11 +0100 Subject: [PATCH 2/9] docs: update roles and permissions architecture and implementation plan --- docs/roles-and-permissions-architecture.md | 3969 +++++++++-------- ...les-and-permissions-implementation-plan.md | 3829 +++++++--------- docs/roles-and-permissions-overview.md | 506 +++ 3 files changed, 4159 insertions(+), 4145 deletions(-) create mode 100644 docs/roles-and-permissions-overview.md diff --git a/docs/roles-and-permissions-architecture.md b/docs/roles-and-permissions-architecture.md index f9de090..fa45d86 100644 --- a/docs/roles-and-permissions-architecture.md +++ b/docs/roles-and-permissions-architecture.md @@ -1,46 +1,67 @@ -# Roles and Permissions Architecture +# Roles and Permissions Architecture - Technical Specification -**Project:** Mila - Membership Management System -**Feature:** Role-Based Access Control (RBAC) with Permission Sets -**Version:** 1.0 -**Last Updated:** 2025-11-10 -**Status:** Architecture Design +**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 -1. [Overview](#overview) -2. [Requirements Analysis](#requirements-analysis) -3. [Evaluated Approaches](#evaluated-approaches) -4. [Selected Architecture](#selected-architecture) -5. [Database Schema](#database-schema) -6. [Permission System Design](#permission-system-design) -7. [Implementation Details](#implementation-details) -8. [Future Extensions](#future-extensions) -9. [Migration Strategy](#migration-strategy) -10. [Security Considerations](#security-considerations) +- [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 describes the architecture for implementing a flexible, scalable role-based access control (RBAC) system for the Mila membership management application. The system provides: - -- **Predefined Permission Sets** with configurable permissions -- **Dynamic Roles** that reference permission sets -- **Resource-level and Action-level** authorization -- **Page-level** access control for LiveView routes -- **Special handling** for credentials and linked user-member relationships -- **Future extensibility** for field-level permissions +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. **Separation of Concerns:** Permission Sets (what you can do) vs. Roles (job titles/functions) -2. **Flexibility:** Admins can configure permissions at runtime via database -3. **Performance:** Leverage Ash Framework policies with ETS caching -4. **Extensibility:** Architecture supports future field-level granularity -5. **Consistency:** Single unified permission model for all resources +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 --- @@ -48,1011 +69,1101 @@ This document describes the architecture for implementing a flexible, scalable r ### Core Requirements -Based on the project requirements, the system must support: +**1. Predefined Permission Sets** -#### Permission Sets (4 Predefined) +Four hardcoded permission sets that define access patterns: -1. **Own Data** - Users can only access their own data -2. **Read-Only** - Read access to all members, groups, and custom fields -3. **Normal User** - Read and write access to members and custom fields -4. **Admin** - Full access to all resources including user management +- **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 -#### Example Roles +**2. Roles Stored in Database** -- **Mitglied** (Member) - Default role, own data access -- **Vorstand** (Board) - Access to members, not users -- **Kassenwart** (Treasurer) - Access to payment information -- **Buchhaltung** (Accounting) - Read-only access -- **Admin** - Full administrative access +Five predefined roles stored in the `roles` table: -#### Authorization Granularity +- **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 -**Resource Level (Phase 1 - Now):** -- Member: read, create, update, destroy -- User: read, create, update, destroy -- PropertyType: read, create, update, destroy -- Property: read, create, update, destroy -- Role: read, create, update, destroy -- Payment (future): read, create, update, destroy +**3. Resource-Level Permissions** -**Page Level:** -- Control access to LiveView pages -- Pages are read-only access checks -- Edit pages require both page access AND resource write permission +Control CRUD operations on: +- User (credentials, profile) +- Member (member data) +- Property (custom field values) +- PropertyType (custom field definitions) +- Role (role management) -**Field Level (Phase 2 - Later):** -- Restrict read/write access to specific member fields -- Restrict access to specific custom field types -- Example: Treasurer sees payment_history, Board does not +**4. Page-Level Permissions** -#### Special Cases +Control access to LiveView pages: +- Index pages (list views) +- Show pages (detail views) +- Form pages (create/edit) +- Admin pages -1. **User Credentials:** - - Users can ALWAYS edit their own credentials (email, password) - - Only Admins can edit OTHER users' credentials - - Email field of members linked to users can only be edited by Admins +**5. Granular Scopes** -2. **Required Fields:** - - When creating a member with required custom fields, user must be able to write those fields - - Even if user normally doesn't have write permission for that field type - - After creation, normal permissions apply +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 -3. **Payment History (Future):** - - Configurable per permission set - - Members may or may not see their own payment history +**6. Special Cases** -4. **Linked User-Member Relationships:** - - Member email sync follows special rules - - User email is source of truth for linked members +- **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 -#### Constraints +**7. UI Consistency** -- Roles can be added, renamed, or removed by admins -- Permission sets are predefined but permissions are configurable -- Each user has exactly ONE role -- Roles cannot overlap (no multiple role assignment per user) -- "Mitglied" role is a system role and cannot be deleted -- Permission sets are system-defined and cannot be deleted - ---- - -## Evaluated Approaches - -We evaluated four different architectural approaches for implementing the authorization system: - -### Approach 1: Ash Policies + RBAC with JSONB Permissions - -**Description:** Store permissions as JSONB in the Role resource, use custom Ash Policy checks to evaluate them. - -**Database Structure:** -``` -roles (id, name, permissions_config: jsonb) -users (role_id) -``` - -**Permissions stored as:** -```json -{ - "resource_permissions": { - "Member": {"read": true, "update": false} - }, - "page_permissions": { - "/members": true - } -} -``` - -**Advantages:** -- ✅ Simple database schema (fewer tables) -- ✅ Flexible JSON structure -- ✅ Fast schema changes (no migrations needed) -- ✅ Easy to serialize/deserialize - -**Disadvantages:** -- ❌ No referential integrity on permission keys -- ❌ JSONB queries are less efficient than normalized tables -- ❌ Difficult to query "which roles have access to X?" -- ❌ Schema validation happens in application code -- ❌ No indexing on individual permissions -- ❌ Versioning of JSONB structure becomes complex - -**Verdict:** ❌ Not selected - JSONB makes querying and validation difficult - ---- - -### Approach 2: Hybrid RBAC + ABAC with Permission Matrix - -**Description:** Separate tables for every permission type with full granularity from day one. - -**Database Structure:** -``` -roles (id, name) -permissions (id, resource, action, field, condition) -role_permissions (role_id, permission_id, granted) -user_roles (user_id, role_id) -``` - -**Advantages:** -- ✅ Maximum flexibility -- ✅ Highly granular from the start -- ✅ Easy to add new permission types -- ✅ Audit trail built-in - -**Disadvantages:** -- ❌ Very complex database schema -- ❌ High JOIN overhead on every authorization check -- ❌ Over-engineered for current requirements -- ❌ Difficult to cache effectively -- ❌ Performance concerns with many permissions -- ❌ Complex to seed and maintain - -**Verdict:** ❌ Not selected - Too complex for current needs, over-engineering - ---- - -### Approach 3: Policy Graphs with Custom Authorizer - -**Description:** Use Ash Policies for action-level checks, custom Authorizer module for field-level filtering. - -**Database Structure:** -``` -roles (id, name, permission_config) -Custom Authorizer reads config and applies filters -``` - -**Advantages:** -- ✅ Best performance (optimized for Ash) -- ✅ Granular field-level control -- ✅ Can leverage Ash query optimization - -**Disadvantages:** -- ❌ Requires custom authorizer implementation (non-standard) -- ❌ More code to maintain -- ❌ Harder to test than declarative policies -- ❌ Mixes declarative (Policies) and imperative (Authorizer) approaches - -**Verdict:** ❌ Not selected - Too much custom code, reduces maintainability - ---- - -### Approach 4: Simple Role Enum (Quick Start) - -**Description:** Simple `:role` field on User with enum values, policies hardcoded in resources. - -**Database Structure:** -``` -users (role: :admin | :vorstand | :kassenwart | :member) -``` - -**Advantages:** -- ✅ Very simple to implement (1 week) -- ✅ No extra tables needed -- ✅ Fast performance -- ✅ Easy to understand - -**Disadvantages:** -- ❌ No dynamic permission configuration -- ❌ Requires code deployment to change permissions -- ❌ Can't add new roles without code changes -- ❌ Not extensible to field-level permissions -- ❌ Doesn't meet requirement for "configurable permissions" - -**Verdict:** ❌ Not selected - Doesn't meet core requirements +- 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 5: Permission Sets + Normalized Tables (Selected) +### Approach: Hardcoded Permission Sets with Database Roles -**Description:** Hybrid approach that separates Permission Sets (what you can do) from Roles (who you are), with normalized database tables for queryability and Ash Policies for enforcement. - -**Key Innovation:** Introduce **Permission Sets** as an abstraction layer between Roles and actual Permissions. +**Core Concept:** ``` -Permission Set (4 predefined, defines capabilities) - ↓ -Role (many, references one Permission Set) - ↓ -User (each has one Role) +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?** -1. **Meets Requirements:** - - ✅ Configurable permissions (stored in database) - - ✅ Dynamic role creation - - ✅ Extensible to field-level - - ✅ Admin UI can modify at runtime +✅ **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 -2. **Performance:** - - ✅ Normalized tables allow efficient queries - - ✅ Indexes on resource_name and action - - ✅ ETS cache for permission lookups - - ✅ Ash Policies translate to SQL filters +**Trade-offs:** -3. **Maintainability:** - - ✅ Clear separation of concerns - - ✅ Standard Ash patterns (not custom authorizer) - - ✅ Testable with standard Ash policy tests - - ✅ Easy to understand and debug +⚠️ **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 -4. **Extensibility:** - - ✅ `field_name` column reserved for Phase 2 - - ✅ `scope` system handles "own" vs "all" vs "linked" - - ✅ New resources just add permission rows - - ✅ No code changes needed for new roles +### System Architecture Diagram -5. **Flexibility:** - - ✅ Permission Sets ensure consistency - - ✅ Roles can be renamed without changing permissions - - ✅ Multiple roles can share same permission set - - ✅ Admin can configure at runtime +``` +┌─────────────────────────────────────────────────────────────┐ +│ Authorization System │ +└─────────────────────────────────────────────────────────────┘ -**Trade-offs Accepted:** -- More tables than JSONB approach (but better queryability) -- More rows than enum approach (but runtime configurable) -- Not as granular as full ABAC (but simpler to manage) +┌──────────────────┐ +│ 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 +## 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 ``` -┌─────────────────────┐ -│ permission_sets │ -│─────────────────────│ -│ id (PK) │ -│ name │◄───────┐ -│ description │ │ -│ is_system │ │ -└─────────────────────┘ │ - │ - │ -┌─────────────────────────────┐│ -│ permission_set_resources ││ -│─────────────────────────────││ -│ id (PK) ││ -│ permission_set_id (FK) │┘ -│ resource_name │ -│ action │ -│ scope │ -│ field_name (nullable) │ -│ granted │ -└─────────────────────────────┘ - -┌─────────────────────────────┐ -│ permission_set_pages │ -│─────────────────────────────│ -│ id (PK) │ -│ permission_set_id (FK) │───┐ -│ page_path │ │ -└─────────────────────────────┘ │ - │ - │ -┌─────────────────────┐ │ -│ roles │ │ -│─────────────────────│ │ -│ id (PK) │ │ -│ name │ │ -│ description │ │ -│ permission_set_id │──────────┘ -│ is_system_role │ -└─────────────────────┘ - ▲ - │ - │ -┌─────────────────────┐ -│ users │ -│─────────────────────│ -│ id (PK) │ -│ email │ -│ hashed_password │ -│ oidc_id │ -│ member_id (FK) │ -│ role_id (FK) │◄──── Default: "Mitglied" role -└─────────────────────┘ +┌─────────────────────────────────┐ +│ 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 -#### `permission_sets` +#### roles -Defines the 4 core permission sets. These are system-defined and cannot be deleted. - -```sql -CREATE TABLE permission_sets ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL UNIQUE, - description TEXT, - is_system BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMP NOT NULL DEFAULT now(), - updated_at TIMESTAMP NOT NULL DEFAULT now() -); - --- Indexes -CREATE INDEX idx_permission_sets_name ON permission_sets(name); -``` - -**Records:** -- `own_data` - Users can only access their own data -- `read_only` - Read access to all members and custom fields -- `normal_user` - Read and write access to members and custom fields -- `admin` - Full access to everything - ---- - -#### `permission_set_resources` - -Defines what actions each permission set can perform on which resources. - -```sql -CREATE TABLE permission_set_resources ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - permission_set_id UUID NOT NULL REFERENCES permission_sets(id) ON DELETE CASCADE, - resource_name VARCHAR(255) NOT NULL, -- "Member", "User", "PropertyType", etc. - action VARCHAR(50) NOT NULL, -- "read", "create", "update", "destroy" - scope VARCHAR(50), -- NULL/"all", "own", "linked" - field_name VARCHAR(255), -- NULL = all fields, else specific field (Phase 2) - granted BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMP NOT NULL DEFAULT now(), - updated_at TIMESTAMP NOT NULL DEFAULT now() -); - --- Indexes -CREATE INDEX idx_psr_permission_set ON permission_set_resources(permission_set_id); -CREATE INDEX idx_psr_resource_action ON permission_set_resources(resource_name, action); -CREATE UNIQUE INDEX idx_psr_unique ON permission_set_resources( - permission_set_id, resource_name, action, - COALESCE(scope, 'all'), COALESCE(field_name, '') -); -``` - -**Scope Values:** -- `NULL` or `"all"` - Permission applies to all entities of this resource -- `"own"` - Permission applies only to user's own data (user.id == actor.id) -- `"linked"` - Permission applies only to entities linked to user (e.g., member.user_id == actor.id) - -**Field Name (Phase 2):** -- `NULL` - Permission applies to all fields (Phase 1 default) -- `"field_name"` - Permission applies only to specific field (Phase 2) - -**Example Records:** -```sql --- Own Data Permission Set: User can read their own User record -INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) -VALUES (own_data_id, 'User', 'read', 'own', true); - --- Read-Only Permission Set: Can read all Members -INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) -VALUES (read_only_id, 'Member', 'read', 'all', true); - --- Normal User Permission Set: Can update all Members -INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) -VALUES (normal_user_id, 'Member', 'update', 'all', true); - --- Admin Permission Set: Can destroy all Members -INSERT INTO permission_set_resources (permission_set_id, resource_name, action, scope, granted) -VALUES (admin_id, 'Member', 'destroy', 'all', true); -``` - ---- - -#### `permission_set_pages` - -Defines which LiveView pages each permission set can access. - -```sql -CREATE TABLE permission_set_pages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - permission_set_id UUID NOT NULL REFERENCES permission_sets(id) ON DELETE CASCADE, - page_path VARCHAR(255) NOT NULL, -- "/members", "/members/:id/edit", "/admin" - created_at TIMESTAMP NOT NULL DEFAULT now(), - updated_at TIMESTAMP NOT NULL DEFAULT now() -); - --- Indexes -CREATE INDEX idx_psp_permission_set ON permission_set_pages(permission_set_id); -CREATE INDEX idx_psp_page_path ON permission_set_pages(page_path); -CREATE UNIQUE INDEX idx_psp_unique ON permission_set_pages(permission_set_id, page_path); -``` - -**Page Paths:** -- Static paths: `/members`, `/users`, `/admin` -- Dynamic paths: `/members/:id`, `/members/:id/edit` -- Must match Phoenix Router routes exactly - -**Important:** Page permissions are READ-ONLY access checks. If a user shouldn't access an edit page, they don't get the page permission. The actual write operation is controlled by resource permissions. - -**Example Records:** -```sql --- Own Data: Only profile page -INSERT INTO permission_set_pages (permission_set_id, page_path) -VALUES (own_data_id, '/profile'); - --- Read-Only: Member index and show pages -INSERT INTO permission_set_pages (permission_set_id, page_path) -VALUES - (read_only_id, '/members'), - (read_only_id, '/members/:id'); - --- Normal User: Member pages including edit -INSERT INTO permission_set_pages (permission_set_id, page_path) -VALUES - (normal_user_id, '/members'), - (normal_user_id, '/members/new'), - (normal_user_id, '/members/:id'), - (normal_user_id, '/members/:id/edit'); - --- Admin: All pages -INSERT INTO permission_set_pages (permission_set_id, page_path) -VALUES - (admin_id, '/members'), - (admin_id, '/members/new'), - (admin_id, '/members/:id'), - (admin_id, '/members/:id/edit'), - (admin_id, '/users'), - (admin_id, '/users/new'), - (admin_id, '/users/:id'), - (admin_id, '/users/:id/edit'), - (admin_id, '/admin'); -``` - ---- - -#### `roles` - -Defines user roles that reference one permission set each. +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_id UUID NOT NULL REFERENCES permission_sets(id) ON DELETE RESTRICT, + permission_set_name VARCHAR(50) NOT NULL, is_system_role BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMP NOT NULL DEFAULT now(), - updated_at TIMESTAMP NOT NULL DEFAULT now() + 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')) ); --- Indexes -CREATE INDEX idx_roles_name ON roles(name); -CREATE INDEX idx_roles_permission_set ON roles(permission_set_id); +CREATE UNIQUE INDEX roles_name_index ON roles (name); +CREATE INDEX roles_permission_set_name_index ON roles (permission_set_name); ``` -**System Roles:** -- `is_system_role = true` for "Mitglied" (default role) -- System roles cannot be deleted -- Can be renamed but must always exist +**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") -**Example Records:** -```sql --- Mitglied (default role for all users) -INSERT INTO roles (name, description, permission_set_id, is_system_role) -VALUES ('Mitglied', 'Standard role for all members', own_data_id, true); +**Constraints:** +- `name` must be unique +- `permission_set_name` must be one of 4 valid values +- System roles cannot be deleted (enforced in Ash resource) --- Vorstand (board member with read access) -INSERT INTO roles (name, description, permission_set_id, is_system_role) -VALUES ('Vorstand', 'Board member with read access to all members', read_only_id, false); +#### users (modified) --- Kassenwart (treasurer with write access + payment info) -INSERT INTO roles (name, description, permission_set_id, is_system_role) -VALUES ('Kassenwart', 'Treasurer with access to payment information', normal_user_id, false); - --- Buchhaltung (accounting with read access) -INSERT INTO roles (name, description, permission_set_id, is_system_role) -VALUES ('Buchhaltung', 'Accounting with read-only access', read_only_id, false); - --- Admin (full access) -INSERT INTO roles (name, description, permission_set_id, is_system_role) -VALUES ('Admin', 'Full administrative access', admin_id, false); -``` - ---- - -#### `users` (Extended) - -Add `role_id` foreign key to existing users table. +Add foreign key to roles table. ```sql -ALTER TABLE users -ADD COLUMN role_id UUID REFERENCES roles(id) ON DELETE RESTRICT; +ALTER TABLE users + ADD COLUMN role_id UUID REFERENCES roles(id) ON DELETE RESTRICT; --- Set default to "Mitglied" role (via migration) -UPDATE users SET role_id = (SELECT id FROM roles WHERE name = 'Mitglied') WHERE role_id IS NULL; - -ALTER TABLE users ALTER COLUMN role_id SET NOT NULL; - --- Index -CREATE INDEX idx_users_role ON users(role_id); +CREATE INDEX users_role_id_index ON users (role_id); ``` ---- +**ON DELETE RESTRICT:** Prevents deleting a role if users are assigned to it. -## Permission System Design +### Seed Data -### Permission Evaluation Flow +Five predefined roles created during initial setup: -``` -Request comes in (LiveView mount or Ash action) - ↓ -1. Load Current User with Role preloaded - ↓ -2. Check Page Permission (if LiveView) - - Query: permission_set_pages WHERE page_path = current_path - - If no match: DENY, redirect to "/" - ↓ -3. Ash Policy Check (for resource actions) - - Policy 1: Check "relates_to_actor" (own data) - - Policy 2: Check custom permission via DB - - Load permission_set_resources - - Match: resource_name, action, scope - - Evaluate scope: - * "own" → Filter: id == actor.id - * "linked" → Filter: user_id == actor.id - * "all" → No filter - - Policy 3: Default DENY - ↓ -4. Special Validations (if applicable) - - Member email change on linked member - - Required fields on create - ↓ -5. Execute Action or Render Page -``` - -### Scope Evaluation - -The `scope` field determines which subset of records a permission applies to: - -#### Scope: `"own"` - -Used for resources where user has direct ownership. - -**Applicable to:** `User` - -**Filter Logic:** ```elixir -{:filter, expr(id == ^actor.id)} -``` +# priv/repo/seeds/authorization_seeds.exs -**Example:** -- Own Data permission set has `User.read` with scope `"own"` -- User can only read their own User record -- Query becomes: `SELECT * FROM users WHERE id = $actor_id` +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) +``` --- -#### Scope: `"linked"` +## Permission System Design (MVP) -Used for resources linked to user via intermediate relationship. +### PermissionSets Module -**Applicable to:** `Member`, `Property`, `Payment` (future) +**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 -**Filter Logic:** ```elixir -# For Member -{:filter, expr(user_id == ^actor.id)} +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. + """ -# For Property (traverses relationship) -{:filter, expr(member.user_id == ^actor.id)} + @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()] + } -# For Payment (future, traverses relationship) -{:filter, expr(member.user_id == ^actor.id)} + @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 ``` -**Example:** -- Own Data permission set has `Member.read` with scope `"linked"` -- User can only read Members linked to them (member.user_id == actor.id) -- If user has no linked member: no results -- Query becomes: `SELECT * FROM members WHERE user_id = $actor_id` +#### Permission Matrix ---- +Quick reference table showing what each permission set allows: -#### Scope: `"all"` or `NULL` +| 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 | -Used for full access to all records of a resource. +**Legend:** R=Read, C=Create, U=Update, D=Destroy -**Applicable to:** All resources +### 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. -**Filter Logic:** ```elixir -:authorized # No filter, all records allowed +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 ``` -**Example:** -- Read-Only permission set has `Member.read` with scope `"all"` -- User can read all Members -- Query becomes: `SELECT * FROM members` (no WHERE clause for authorization) +**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 --- -### Policy Implementation in Ash Resources +## Resource Policies -Each Ash resource defines policies that check permissions: +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 - # Policy 1: Users can always access their own linked member data - # This bypasses permission checks for own data + # SPECIAL CASE: Users can always access their linked member policy action_type([:read, :update]) do - description "Users can always access their own member data if linked" - authorize_if relates_to_actor_via(:user) + description "Users can access member linked to their account" + authorize_if expr(user_id == ^actor(:id)) end - # Policy 2: Check database permissions - # This is where permission_set_resources table is queried + # GENERAL: Check permissions from role policy action_type([:read, :create, :update, :destroy]) do - description "Check if actor's role has permission for this action" - authorize_if Mv.Authorization.Checks.HasResourcePermission.for_action() + description "Check permissions from user's role" + authorize_if Mv.Authorization.Checks.HasPermission end - # Policy 3: Default deny - # If no policy matched, forbid access + # DEFAULT: Forbid policy action_type([:read, :create, :update, :destroy]) do forbid_if always() end end -end -``` - -**Important:** Policy order matters! First matching policy wins. - ---- - -### Custom Policy Check Implementation - -```elixir -defmodule Mv.Authorization.Checks.HasResourcePermission do - @moduledoc """ - Custom Ash Policy Check that evaluates database-stored permissions. - Queries the permission_set_resources table based on actor's role - and evaluates scope to return appropriate filter. - """ - - use Ash.Policy.Check - - @impl true - def type, do: :filter - - @impl true - def match?(actor, context, _opts) do - resource = context.resource - action = context.action - - # Load actor's permission set (with caching) - case get_permission_set(actor) do - nil -> - :forbidden - - permission_set -> - # Query permission_set_resources table - check_permission(permission_set.id, resource, action.name, actor, context) - end - end - - defp get_permission_set(nil), do: nil - defp get_permission_set(actor) do - # Try cache first (ETS) - case Mv.Authorization.PermissionCache.get_permission_set(actor.id) do - {:ok, permission_set} -> - permission_set - - :miss -> - # Load from database: user → role → permission_set - load_and_cache_permission_set(actor) - end - end - - defp load_and_cache_permission_set(actor) do - case Ash.load(actor, role: :permission_set) do - {:ok, user_with_relations} -> - permission_set = user_with_relations.role.permission_set - Mv.Authorization.PermissionCache.put_permission_set(actor.id, permission_set) - permission_set - - _ -> - nil - end - end - - defp check_permission(permission_set_id, resource, action, actor, context) do - resource_name = resource |> Module.split() |> List.last() - - # Query permission_set_resources - query = - Mv.Authorization.PermissionSetResource - |> Ash.Query.filter( - permission_set_id == ^permission_set_id and - resource_name == ^resource_name and - action == ^action and - is_nil(field_name) # Phase 1: only resource-level - ) - - case Ash.read_one(query) do - {:ok, permission} -> - evaluate_permission(permission, actor, context) - - _ -> - :forbidden - end - end - - defp evaluate_permission(%{granted: false}, _actor, _context) do - :forbidden - end - - defp evaluate_permission(%{granted: true, scope: nil}, _actor, _context) do - :authorized - end - - defp evaluate_permission(%{granted: true, scope: "all"}, _actor, _context) do - :authorized - end - - defp evaluate_permission(%{granted: true, scope: "own"}, actor, _context) do - # Return filter expression for Ash - {:filter, expr(id == ^actor.id)} - end - - defp evaluate_permission(%{granted: true, scope: "linked"}, actor, context) do - resource = context.resource - - # Generate appropriate filter based on resource - case resource do - Mv.Membership.Member -> - {:filter, expr(user_id == ^actor.id)} - - Mv.Membership.Property -> - {:filter, expr(member.user_id == ^actor.id)} - - # Add more resources as needed - - _ -> - :forbidden - end - end -end -``` - ---- - -## Implementation Details - -### Phase 1: Resource and Page Level Permissions - -**Timeline:** Sprint 1-2 (2-3 weeks) - -**Deliverables:** -1. Database migrations for all permission tables -2. Ash resources for PermissionSet, Role, PermissionSetResource, PermissionSetPage -3. Custom policy checks -4. Permission cache (ETS) -5. Router plug for page permissions -6. Seeds for 4 permission sets and 5 roles -7. Admin UI for role management -8. Tests for all permission scenarios - -**Not Included in Phase 1:** -- Field-level permissions (field_name is always NULL) -- Payment history (resource doesn't exist yet) -- Groups (not yet planned) - ---- - -### Special Cases Implementation - -#### 1. User Credentials - Always Editable by Owner - -**Requirement:** Users can always edit their own email and password, regardless of permission set. - -**Implementation:** - -```elixir -defmodule Mv.Accounts.User do - policies do - # Policy 1: Users can ALWAYS read and update their own credentials - # This comes BEFORE permission checks - policy action_type([:read, :update]) do - description "Users can always access and update their own credentials" - authorize_if expr(id == ^actor(:id)) - end - - # Policy 2: Check permission set (for admins accessing other users) - policy action_type([:read, :create, :update, :destroy]) do - authorize_if Mv.Authorization.Checks.HasResourcePermission.for_action() - end - - # Policy 3: Default deny - policy action_type([:read, :create, :update, :destroy]) do - forbid_if always() - end - end -end -``` - -**Result:** -- Mitglied role: Can edit own User record (own email/password) -- Admin role: Can edit ANY User record (including others' credentials) -- Other roles: Cannot access User resource unless specifically granted - ---- - -#### 2. Member Email for Linked Members - Admin Only - -**Requirement:** If a member is linked to a user, only admins can edit the member's email field. - -**Implementation:** - -```elixir -defmodule Mv.Membership.Member do + # Custom validation for email editing (see Special Cases section) validations do - validate fn changeset, context -> - # Only check if email is being changed - if Ash.Changeset.changing_attribute?(changeset, :email) do - member = changeset.data - actor = context.actor - - # Load member's user relationship - case Ash.load(member, :user) do - {:ok, %{user: %{id: _user_id}}} -> - # Member IS linked to a user - # Check if actor has permission to edit ALL users - if has_permission_for_all_users?(actor) do - :ok - else - {:error, - field: :email, - message: "Only admins can edit email of members linked to users"} - end - - {:ok, %{user: nil}} -> - # Member is NOT linked - # Normal Member.update permission applies - :ok - - {:error, _} -> - :ok - end - else - :ok - end + validate changing(:email), on: :update do + validate &validate_linked_member_email_change/2 end end - defp has_permission_for_all_users?(actor) do - # Check if actor's permission set has User.update with scope="all" - permission_set = get_permission_set(actor) - - Mv.Authorization.PermissionSetResource - |> Ash.Query.filter( - permission_set_id == ^permission_set.id and - resource_name == "User" and - action == "update" and - scope == "all" and - granted == true - ) - |> Ash.exists?() - end + # ... end ``` -**Result:** -- Admin: Can edit email of any member (including linked ones) -- Normal User/Read-Only: Can edit email of unlinked members only -- Attempting to edit email of linked member without permission: Validation error +**Permission Matrix:** ---- +| Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | +|--------|----------|----------|------------|-------------|-------| +| Read linked | ✅ (special) | ✅ (if linked) | ✅ | ✅ (if linked) | ✅ | +| Update linked | ✅ (special)* | ❌ | ✅* | ❌ | ✅ | +| Read all | ❌ | ✅ | ✅ | ✅ | ✅ | +| Create | ❌ | ❌ | ✅ | ❌ | ✅ | +| Destroy | ❌ | ❌ | ❌ | ❌ | ✅ | -#### 3. Required Custom Fields on Member Creation +*Email editing has additional validation (see Special Cases) -**Requirement:** When creating a member with required custom fields, user must be able to set those fields even if they normally don't have permission. +### Property Resource Policies -**Implementation:** +**Location:** `lib/mv/membership/property.ex` -For Phase 1, this is not an issue because: -- PropertyType.required flag exists but isn't enforced yet -- No field-level permissions exist yet -- If user has Member.create permission, they can set Properties - -For Phase 2 (when field-level permissions exist): +**Special Case:** Users can access properties of their linked member. ```elixir defmodule Mv.Membership.Property do - actions do - create :create_property do - # Special handling for required properties during member creation - change Mv.Authorization.Changes.AllowRequiredPropertyOnMemberCreate - end - end -end - -defmodule Mv.Authorization.Changes.AllowRequiredPropertyOnMemberCreate do - use Ash.Resource.Change + use Ash.Resource, ... - def change(changeset, _opts, context) do - # Check if this is part of a member creation - if creating_member?(context) do - property_type_id = Ash.Changeset.get_attribute(changeset, :property_type_id) - - # Load PropertyType - case Ash.get(Mv.Membership.PropertyType, property_type_id) do - {:ok, %{required: true}} -> - # This is a required field, allow creation even without normal permission - # Set special context flag - Ash.Changeset.set_context(changeset, :bypass_property_permission, true) - - _ -> - changeset - end - else - changeset + 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 Implementation +## Page Permission System -**Router Configuration:** +Page permissions control which LiveView pages a user can access. This is enforced **before** the LiveView mounts via a Phoenix Plug. -```elixir -defmodule MvWeb.Router do - use MvWeb, :router - import MvWeb.Authorization - - # Pipeline with permission check - pipeline :require_page_permission do - plug :put_secure_browser_headers - plug :fetch_current_user - plug MvWeb.Plugs.CheckPagePermission - end - - scope "/", MvWeb do - pipe_through [:browser, :require_authenticated_user, :require_page_permission] - - # These routes automatically check page permissions - live "/members", MemberLive.Index, :index - live "/members/new", MemberLive.Index, :new - live "/members/:id", MemberLive.Show, :show - live "/members/:id/edit", MemberLive.Index, :edit - - live "/users", UserLive.Index, :index - live "/users/new", UserLive.Index, :new - live "/users/:id", UserLive.Show, :show - live "/users/:id/edit", UserLive.Index, :edit - - live "/property-types", PropertyTypeLive.Index, :index - live "/property-types/new", PropertyTypeLive.Index, :new - live "/property-types/:id/edit", PropertyTypeLive.Index, :edit - - live "/admin", AdminLive.Dashboard, :index - end -end -``` +### CheckPagePermission Plug -**Page Permission 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. - Queries permission_set_pages table based on user's role → permission_set. + ## 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) @@ -1060,425 +1171,386 @@ defmodule MvWeb.Plugs.CheckPagePermission do 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 - # Extract route template from conn - # "/members/:id/edit" from actual "/members/123/edit" case conn.private[:phoenix_route] do - {_, _, _, route_template, _} -> route_template - _ -> conn.request_path - end - end - - defp has_page_permission?(nil, _page_path), do: false - defp has_page_permission?(user, page_path) do - # Try cache first - case Mv.Authorization.PermissionCache.get_page_permission(user.id, page_path) do - {:ok, has_permission} -> - has_permission - - :miss -> - # Load from database and cache - has_permission = check_page_permission_db(user, page_path) - Mv.Authorization.PermissionCache.put_page_permission(user.id, page_path, has_permission) - has_permission - end - end - - defp check_page_permission_db(user, page_path) do - # Load user → role → permission_set - case Ash.load(user, role: :permission_set) do - {:ok, user_with_relations} -> - permission_set_id = user_with_relations.role.permission_set.id - - # Check if permission_set_pages has this page - Mv.Authorization.PermissionSetPage - |> Ash.Query.filter( - permission_set_id == ^permission_set_id and - page_path == ^page_path - ) - |> Ash.exists?() + {_plug, _opts, _pipe, route_template, _meta} -> + route_template _ -> - false + conn.request_path end end -end -``` -**Important:** Both page permission AND resource permission must be true for edit operations: -- User needs `/members/:id/edit` page permission to see the page -- User needs `Member.update` permission to actually save changes -- If user has page permission but not resource permission: page loads but save fails - ---- - -### Permission Cache Implementation - -**ETS Cache for Performance:** - -```elixir -defmodule Mv.Authorization.PermissionCache do - @moduledoc """ - ETS-based cache for user permissions to avoid database lookups on every request. - - Cache stores: - - User's permission_set (user_id → permission_set) - - Page permissions (user_id + page_path → boolean) - - Resource permissions (user_id + resource + action → permission) - - Cache is invalidated when: - - User's role changes - - Role's permission_set changes - - Permission set's permissions change - """ - - use GenServer - - @table_name :permission_cache - - # Client API - - def start_link(_) do - GenServer.start_link(__MODULE__, [], name: __MODULE__) + # Check if user has permission for page + defp has_page_permission?(nil, _page_path) do + false end - def get_permission_set(user_id) do - case :ets.lookup(@table_name, {:permission_set, user_id}) do - [{_, permission_set}] -> {:ok, permission_set} - [] -> :miss + 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 - - def put_permission_set(user_id, permission_set) do - :ets.insert(@table_name, {{:permission_set, user_id}, permission_set}) - :ok - end - - def get_page_permission(user_id, page_path) do - case :ets.lookup(@table_name, {:page, user_id, page_path}) do - [{_, has_permission}] -> {:ok, has_permission} - [] -> :miss - end - end - - def put_page_permission(user_id, page_path, has_permission) do - :ets.insert(@table_name, {{:page, user_id, page_path}, has_permission}) - :ok - end - - def invalidate_user(user_id) do - # Delete all entries for this user - :ets.match_delete(@table_name, {{:permission_set, user_id}, :_}) - :ets.match_delete(@table_name, {{:page, user_id, :_}, :_}) - :ok - end - - def invalidate_all do - :ets.delete_all_objects(@table_name) - :ok - end - - # Server Callbacks - - def init(_) do - table = :ets.new(@table_name, [ - :set, - :public, - :named_table, - read_concurrency: true, - write_concurrency: true - ]) - {:ok, %{table: table}} - end -end -``` -**Cache Invalidation Strategy:** - -```elixir -defmodule Mv.Authorization.Role do - # After updating role's permission_set - changes do - change after_action(fn changeset, role, _context -> - # Invalidate cache for all users with this role - invalidate_users_with_role(role.id) - {:ok, role} - end), on: [:update] - end - - defp invalidate_users_with_role(role_id) do - # Find all users with this role - users = - Mv.Accounts.User - |> Ash.Query.filter(role_id == ^role_id) - |> Ash.read!() - - # Invalidate each user's cache - Enum.each(users, fn user -> - Mv.Authorization.PermissionCache.invalidate_user(user.id) + # 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 -end -defmodule Mv.Authorization.PermissionSetResource do - # After updating permissions - changes do - change after_action(fn changeset, permission, _context -> - # Invalidate all users with this permission set - invalidate_permission_set(permission.permission_set_id) - {:ok, permission} - end), on: [:create, :update, :destroy] - end - - defp invalidate_permission_set(permission_set_id) do - # Find all roles with this permission set - roles = - Mv.Authorization.Role - |> Ash.Query.filter(permission_set_id == ^permission_set_id) - |> Ash.read!() + # 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) - # Invalidate all users with these roles - Enum.each(roles, fn role -> - invalidate_users_with_role(role.id) - end) + # 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 -### UI-Level Authorization +Add plug to protected routes: -**Requirement:** The user interface should only display links, buttons, and fields that the user has permission to access. This improves UX and prevents confusion. +```elixir +defmodule MvWeb.Router do + use MvWeb, :router -**Key Principles:** -1. **Navigation Links:** Hide links to pages the user cannot access -2. **Action Buttons:** Hide "Edit", "Delete", "New" buttons when user lacks permissions -3. **Form Fields:** In Phase 2, hide fields the user cannot read/write -4. **Proactive UI:** Never show a clickable element that would result in "Forbidden" + 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`) --- -#### Implementation Approach +## UI-Level Authorization -**Helper Module:** `MvWeb.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. + UI-level authorization helpers for LiveView templates. - These helpers check permissions and determine what UI elements to show. - They work in conjunction with Ash Policies (which are the actual enforcement). + 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.PermissionCache - alias Mv.Authorization - + alias Mv.Authorization.PermissionSets + @doc """ - Checks if actor can perform action on resource. + Checks if user has permission for an action on a resource (atom). ## Examples - # In LiveView template - <%= if can?(@current_user, :update, Mv.Membership.Member) do %> - - <% end %> + iex> admin = %{role: %{permission_set_name: "admin"}} + iex> can?(admin, :create, Mv.Membership.Member) + true - # In LiveView module - if can?(socket.assigns.current_user, :create, Mv.Membership.PropertyType) do - # Show "New Custom Field" button - end + 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 - resource_name = resource_name(resource) - - # Check cache first - case get_permission_from_cache(user.id, resource_name, action) do - {:ok, result} -> result - :miss -> check_permission_from_db(user, resource_name, action) + 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 actor can access a specific page path. + 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 - # In navigation component - <%= if can_access_page?(@current_user, "/members") do %> - <.link navigate="/members">Members - <% end %> - """ - def can_access_page?(nil, _page_path), do: false - - def can_access_page?(user, page_path) do - # Check cache first - case PermissionCache.get_page_permission(user.id, page_path) do - {:ok, result} -> result - :miss -> check_page_permission_from_db(user, page_path) - end - end - - @doc """ - Checks if actor can perform action on a specific record. - - This respects scope restrictions (own, linked, all). - - ## Examples - - # Show edit button only if user can edit THIS member - <%= if can?(@current_user, :update, member) do %> - - <% end %> + 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 - resource_name = resource_name(resource) - - # First check if user has any permission for this action - case get_permission_from_cache(user.id, resource_name, action) do - {:ok, false} -> - false + 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) - {:ok, true} -> - # User has permission, now check scope - check_scope_for_record(user, action, resource, record) + # Find matching permission + matching_perm = Enum.find(permissions.resources, fn perm -> + perm.resource == resource_name and + perm.action == action and + perm.granted + end) - :miss -> - check_permission_and_scope_from_db(user, action, resource, record) + 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. - # Private helpers + ## Examples - defp resource_name(Mv.Accounts.User), do: "User" - defp resource_name(Mv.Membership.Member), do: "Member" - defp resource_name(Mv.Membership.Property), do: "Property" - defp resource_name(Mv.Membership.PropertyType), do: "PropertyType" - - defp get_permission_from_cache(user_id, resource_name, action) do - # Try to get from cache - # Returns {:ok, true}, {:ok, false}, or :miss - case PermissionCache.get_permission_set(user_id) do - {:ok, permission_set} -> - # Check if this permission set has the permission - has_permission = - permission_set.resources - |> Enum.any?(fn p -> - p.resource_name == resource_name and - p.action == to_string(action) and - p.granted == true - end) - - {:ok, has_permission} + iex> admin = %{role: %{permission_set_name: "admin"}} + iex> can_access_page?(admin, "/admin/roles") + true - :miss -> - :miss + 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_permission_from_db(user, resource_name, action) do - # Load user's role and permission set - user = Ash.load!(user, role: [permission_set: :resources]) - - has_permission = - user.role.permission_set.resources - |> Enum.any?(fn p -> - p.resource_name == resource_name and - p.action == to_string(action) and - p.granted == true - end) - - # Cache the entire permission set - PermissionCache.put_permission_set(user.id, user.role.permission_set) - - has_permission + defp check_scope(:own, user, record, _resource_name) do + record.id == user.id end - defp check_page_permission_from_db(user, page_path) do - user = Ash.load!(user, role: [permission_set: :pages]) - - has_access = - user.role.permission_set.pages - |> Enum.any?(fn p -> p.page_path == page_path end) - - # Cache this specific page permission - PermissionCache.put_page_permission(user.id, page_path, has_access) - - has_access - end - - defp check_scope_for_record(user, action, resource, record) do - # Load the permission to check scope - user = Ash.load!(user, role: [permission_set: :resources]) - resource_name = resource_name(resource) - - permission = - user.role.permission_set.resources - |> Enum.find(fn p -> - p.resource_name == resource_name and - p.action == to_string(action) and - p.granted == true - end) - - case permission do - nil -> - false + 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 - %{scope: "all"} -> - true - - %{scope: "own"} when resource == Mv.Accounts.User -> - # Check if record.id == user.id - record.id == user.id - - %{scope: "linked"} when resource == Mv.Membership.Member -> - # Check if record.user_id == user.id - record_with_user = Ash.load!(record, :user) - case record_with_user.user do - nil -> false - %{id: user_id} -> user_id == user.id - end - - %{scope: "linked"} when resource == Mv.Membership.Property -> - # Check if record.member.user_id == user.id - record_with_member = Ash.load!(record, member: :user) - case record_with_member.member do - nil -> false - %{user: nil} -> false - %{user: %{id: user_id}} -> 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 _ -> - false + # Fallback: check user_id + Map.get(record, :user_id) == user.id end end - - defp check_permission_and_scope_from_db(user, action, resource, record) do - case check_permission_from_db(user, resource_name(resource), action) do - false -> false - true -> check_scope_for_record(user, action, resource, record) + + # 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 -#### Usage in LiveView Templates +Make helpers available to all LiveViews: -**Navigation Component:** +```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 @@ -1486,36 +1558,35 @@ end <.link navigate="/">Home - + <%= if can_access_page?(@current_user, "/members") do %> <.link navigate="/members">Members <% end %> - - <%= if can_access_page?(@current_user, "/users") do %> - <.link navigate="/users">Users - <% end %> - - - <%= if can_access_page?(@current_user, "/property-types") do %> - <.link navigate="/property-types">Custom Fields - <% end %> - - + <%= if can_access_page?(@current_user, "/admin/roles") do %> - <.link navigate="/admin/roles">Roles + <% end %> + + + <.link navigate="/profile">Profile ``` -**Index Page with Action Buttons:** +**Index page with conditional "New" button:** ```heex - + -<.table rows={@members}> - <:col :let={member} label="Name"> - <%= member.first_name %> <%= member.last_name %> - - - <:col :let={member} label="Email"> - <%= member.email %> - - - <:col :let={member} label="Actions"> - - <.link navigate={~p"/members/#{member}"} class="btn-secondary"> - Show - - - - <%= if can?(@current_user, :update, member) do %> - <.link patch={~p"/members/#{member}/edit"} class="btn-secondary"> - Edit - - <% end %> - - - <%= if can?(@current_user, :destroy, member) do %> - <.button phx-click="delete" phx-value-id={member.id} class="btn-danger"> - Delete - - <% end %> - - + + + <%= 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:** +**Show page with conditional edit button:** ```heex - - + + +
+

{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.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..2538e7f 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:247 +#: 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:80 #: 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:230 #, 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:124 #: 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:125 #: 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:83 #: 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:233 #, 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:76 #, 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:77 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "Unveränderlich" @@ -313,7 +313,7 @@ msgstr "Mitglied" msgid "Members" msgstr "Mitglieder" -#: lib/mv_web/live/custom_field_live/form.ex:50 +#: lib/mv_web/live/custom_field_live/form.ex:53 #, elixir-autogen, elixir-format msgid "Name" msgstr "Name" @@ -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:209 #, 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:78 #, 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:231 #, 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:248 #: 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:71 #, 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:247 #, 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:131 #, 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:81 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "Benutzerdefiniertes Feld speichern" @@ -641,7 +645,7 @@ msgstr "Benutzerdefiniertes Feld speichern" msgid "Save Custom field value" msgstr "Benutzerdefinierten Feldwert speichern" -#: lib/mv_web/live/custom_field_live/form.ex:45 +#: lib/mv_web/live/custom_field_live/form.ex:48 #, elixir-autogen, elixir-format msgid "Use this form to manage custom_field records in your database." msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten." @@ -655,3 +659,58 @@ msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Date #, elixir-autogen, elixir-format msgid "Custom Fields" msgstr "Benutzerdefinierte Felder" + +#: lib/mv_web/live/user_live/form.ex:209 +#, 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/custom_field_live/form.ex:64 +#, elixir-autogen, elixir-format +msgid "Auto-generated identifier (immutable)" +msgstr "Automatisch generierte Kennung (unveränderlich)" + +#: lib/mv_web/live/user_live/form.ex:184 +#, 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:222 +#, 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:219 +#, elixir-autogen, elixir-format +msgid "Selected" +msgstr "Ausgewählt" + +#: lib/mv_web/live/custom_field_live/form.ex:58 +#, elixir-autogen, elixir-format +msgid "Slug" +msgstr "Slug" + +#: 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" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index d150a60..9af5b30 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:247 +#: 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:80 #: 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:230 #, 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:124 #: 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:125 #: 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:83 #: 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:233 #, 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:76 #, 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:77 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -314,7 +314,7 @@ msgstr "" msgid "Members" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:50 +#: lib/mv_web/live/custom_field_live/form.ex:53 #, elixir-autogen, elixir-format msgid "Name" 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:209 #, 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:78 #, 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:231 #, 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:248 #: 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:71 #, 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:247 #, 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:131 #, 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:81 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" @@ -642,7 +646,7 @@ msgstr "" msgid "Save Custom field value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:45 +#: lib/mv_web/live/custom_field_live/form.ex:48 #, elixir-autogen, elixir-format msgid "Use this form to manage custom_field records in your database." msgstr "" @@ -656,3 +660,58 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Custom Fields" msgstr "" + +#: lib/mv_web/live/user_live/form.ex:209 +#, 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/custom_field_live/form.ex:64 +#, elixir-autogen, elixir-format +msgid "Auto-generated identifier (immutable)" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:184 +#, 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:222 +#, 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:219 +#, elixir-autogen, elixir-format +msgid "Selected" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form.ex:58 +#, elixir-autogen, elixir-format +msgid "Slug" +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..aefc0d9 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:247 +#: 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:80 #: 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:230 #, 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:124 #: 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:125 #: 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:83 #: 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:233 #, 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:76 #, 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:77 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -314,7 +314,7 @@ msgstr "" msgid "Members" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:50 +#: lib/mv_web/live/custom_field_live/form.ex:53 #, elixir-autogen, elixir-format msgid "Name" 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:209 #, 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:78 #, 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:231 #, 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:248 #: 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:71 #, 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:247 #, 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:131 #, 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:81 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" @@ -642,7 +646,7 @@ msgstr "" msgid "Save Custom field value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:45 +#: lib/mv_web/live/custom_field_live/form.ex:48 #, elixir-autogen, elixir-format, fuzzy msgid "Use this form to manage custom_field records in your database." msgstr "" @@ -656,3 +660,58 @@ msgstr "" #, elixir-autogen, elixir-format, fuzzy msgid "Custom Fields" msgstr "" + +#: lib/mv_web/live/user_live/form.ex:209 +#, 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/custom_field_live/form.ex:64 +#, elixir-autogen, elixir-format +msgid "Auto-generated identifier (immutable)" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:184 +#, 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:222 +#, 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:219 +#, elixir-autogen, elixir-format, fuzzy +msgid "Selected" +msgstr "" + +#: lib/mv_web/live/custom_field_live/form.ex:58 +#, elixir-autogen, elixir-format +msgid "Slug" +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/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/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 From bc75a5853a5a0fa78133713a89549058b9544bcc Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 13:48:05 +0100 Subject: [PATCH 5/9] fix: correction of some english translation --- lib/mv_web/live/custom_field_value_live/form.ex | 2 +- priv/gettext/de/LC_MESSAGES/default.po | 10 +++++----- priv/gettext/default.pot | 10 +++++----- priv/gettext/en/LC_MESSAGES/default.po | 10 +++++----- 4 files changed, 16 insertions(+), 16 deletions(-) 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/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index f6acdca..32822bf 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -646,12 +646,12 @@ 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." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index d150a60..1dca601 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -647,12 +647,12 @@ 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 "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index df56e75..e4e1d29 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -647,12 +647,12 @@ 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 "" From edf8b2b79e643b6d3af95c7c76cc602069d1f48a Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 13 Nov 2025 19:17:18 +0100 Subject: [PATCH 6/9] feat: add custom field slug --- docs/database_schema.dbml | 23 +- lib/membership/custom_field.ex | 19 +- .../custom_field/changes/generate_slug.ex | 118 ++++++ lib/mv_web/live/custom_field_live/form.ex | 17 + lib/mv_web/live/custom_field_live/index.ex | 3 +- lib/mv_web/live/custom_field_live/show.ex | 6 +- mix.exs | 3 +- ...251113180429_add_slug_to_custom_fields.exs | 47 +++ .../repo/custom_fields/20251113180429.json | 132 ++++++ test/membership/custom_field_slug_test.exs | 397 ++++++++++++++++++ 10 files changed, 756 insertions(+), 9 deletions(-) create mode 100644 lib/membership/custom_field/changes/generate_slug.ex create mode 100644 priv/repo/migrations/20251113180429_add_slug_to_custom_fields.exs create mode 100644 priv/resource_snapshots/repo/custom_fields/20251113180429.json create mode 100644 test/membership/custom_field_slug_test.exs 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/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/mv_web/live/custom_field_live/form.ex b/lib/mv_web/live/custom_field_live/form.ex index b1d3f86..176edc8 100644 --- a/lib/mv_web/live/custom_field_live/form.ex +++ b/lib/mv_web/live/custom_field_live/form.ex @@ -19,6 +19,9 @@ defmodule MvWeb.CustomFieldLive.Form do - immutable - If true, values cannot be changed after creation (default: false) - required - If true, all members must have this custom field (default: false) + **Read-only (Edit mode only):** + - slug - Auto-generated URL-friendly identifier (immutable) + ## Value Type Selection - `:string` - Text data (unlimited length) - `:integer` - Numeric data @@ -48,6 +51,20 @@ 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")} /> + + <%!-- Show slug in edit mode (read-only) --%> +
+ +
+ {@custom_field.slug} +
+

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

+
+ <.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..bbd8603 100644 --- a/lib/mv_web/live/custom_field_live/index.ex +++ b/lib/mv_web/live/custom_field_live/index.ex @@ -11,6 +11,7 @@ defmodule MvWeb.CustomFieldLive.Index do - Delete custom fields (if no custom field values use them) ## Displayed Information + - Slug: URL-friendly identifier (auto-generated from name) - Name: Unique identifier for the custom field - Value type: Data type constraint (string, integer, boolean, date, email) - Description: Human-readable explanation @@ -43,7 +44,7 @@ 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="Slug">{custom_field.slug} <:col :let={{_id, custom_field}} label="Name">{custom_field.name} diff --git a/lib/mv_web/live/custom_field_live/show.ex b/lib/mv_web/live/custom_field_live/show.ex index 783cb4e..2b2ba65 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,8 @@ defmodule MvWeb.CustomFieldLive.Show do <.list> <:item title="Id">{@custom_field.id} + <:item title="Slug">{@custom_field.slug} + <:item title="Name">{@custom_field.name} <:item title="Description">{@custom_field.description} 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/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/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 From c246ca59dbe79eddb785c53512a48d356e224f8a Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 14:13:56 +0100 Subject: [PATCH 7/9] feat: hide slug from user --- lib/mv_web/live/custom_field_live/form.ex | 16 ---------------- lib/mv_web/live/custom_field_live/index.ex | 3 --- lib/mv_web/live/custom_field_live/show.ex | 7 ++++++- 3 files changed, 6 insertions(+), 20 deletions(-) diff --git a/lib/mv_web/live/custom_field_live/form.ex b/lib/mv_web/live/custom_field_live/form.ex index 176edc8..ab8f104 100644 --- a/lib/mv_web/live/custom_field_live/form.ex +++ b/lib/mv_web/live/custom_field_live/form.ex @@ -19,9 +19,6 @@ defmodule MvWeb.CustomFieldLive.Form do - immutable - If true, values cannot be changed after creation (default: false) - required - If true, all members must have this custom field (default: false) - **Read-only (Edit mode only):** - - slug - Auto-generated URL-friendly identifier (immutable) - ## Value Type Selection - `:string` - Text data (unlimited length) - `:integer` - Numeric data @@ -52,19 +49,6 @@ 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")} /> - <%!-- Show slug in edit mode (read-only) --%> -
- -
- {@custom_field.slug} -
-

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

-
- <.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 bbd8603..65a3ab3 100644 --- a/lib/mv_web/live/custom_field_live/index.ex +++ b/lib/mv_web/live/custom_field_live/index.ex @@ -11,7 +11,6 @@ defmodule MvWeb.CustomFieldLive.Index do - Delete custom fields (if no custom field values use them) ## Displayed Information - - Slug: URL-friendly identifier (auto-generated from name) - Name: Unique identifier for the custom field - Value type: Data type constraint (string, integer, boolean, date, email) - Description: Human-readable explanation @@ -44,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="Slug">{custom_field.slug} - <: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 2b2ba65..239b844 100644 --- a/lib/mv_web/live/custom_field_live/show.ex +++ b/lib/mv_web/live/custom_field_live/show.ex @@ -50,7 +50,12 @@ defmodule MvWeb.CustomFieldLive.Show do <.list> <:item title="Id">{@custom_field.id} - <:item title="Slug">{@custom_field.slug} + <:item title="Slug"> + {@custom_field.slug} +

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

+ <:item title="Name">{@custom_field.name} From efb3e1cc37b7a43ffde8827ef588a8ebd6495b79 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 14:16:34 +0100 Subject: [PATCH 8/9] feat: add translation --- priv/gettext/de/LC_MESSAGES/default.po | 25 +++++++++++++++---------- priv/gettext/default.pot | 25 +++++++++++++++---------- priv/gettext/en/LC_MESSAGES/default.po | 25 +++++++++++++++---------- 3 files changed, 45 insertions(+), 30 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 32822bf..527a279 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -158,7 +158,7 @@ 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 @@ -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,7 +252,7 @@ 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 @@ -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" @@ -355,7 +355,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" @@ -411,7 +411,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" @@ -616,7 +616,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 +631,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" @@ -655,3 +655,8 @@ msgstr "Benutzerdefinierte Felder" #, 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 generierter Identifier" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 1dca601..6035e4a 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -159,7 +159,7 @@ 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 @@ -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,7 +253,7 @@ 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 @@ -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 "" @@ -356,7 +356,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 "" @@ -412,7 +412,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 "" @@ -617,7 +617,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 +632,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 "" @@ -656,3 +656,8 @@ msgstr "" #, 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 "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index e4e1d29..cbc0a5d 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -159,7 +159,7 @@ 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 @@ -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,7 +253,7 @@ 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 @@ -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 "" @@ -356,7 +356,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 "" @@ -412,7 +412,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 "" @@ -617,7 +617,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 +632,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 "" @@ -656,3 +656,8 @@ msgstr "" #, 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 "" From 1819a1e2d120d54983ce25a97e822c827c2c8d6d Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 13 Nov 2025 22:31:32 +0100 Subject: [PATCH 9/9] feat: add user to member linking --- assets/js/app.js | 10 + docs/development-progress-log.md | 129 ++++++ lib/accounts/user.ex | 2 +- lib/membership/member.ex | 77 +++- .../email_not_used_by_other_member.ex | 23 +- lib/mv_web/live/user_live/form.ex | 260 ++++++++++- lib/mv_web/live/user_live/index.ex | 2 +- lib/mv_web/live/user_live/index.html.heex | 7 + mix.lock | 4 +- priv/gettext/de/LC_MESSAGES/default.po | 76 ++- priv/gettext/default.pot | 69 ++- priv/gettext/en/LC_MESSAGES/default.po | 74 ++- test/accounts/debug_changeset_test.exs | 33 ++ .../user_member_linking_email_test.exs | 169 +++++++ test/accounts/user_member_linking_test.exs | 130 ++++++ .../member_available_for_linking_test.exs | 222 +++++++++ .../member_fuzzy_search_linking_test.exs | 158 +++++++ test/mv_web/user_live/form_debug2_test.exs | 48 ++ test/mv_web/user_live/form_debug_test.exs | 52 +++ .../user_live/form_member_linking_ui_test.exs | 433 ++++++++++++++++++ test/mv_web/user_live/form_test.exs | 97 ++++ test/mv_web/user_live/index_test.exs | 31 ++ 22 files changed, 2061 insertions(+), 45 deletions(-) create mode 100644 test/accounts/debug_changeset_test.exs create mode 100644 test/accounts/user_member_linking_email_test.exs create mode 100644 test/accounts/user_member_linking_test.exs create mode 100644 test/membership/member_available_for_linking_test.exs create mode 100644 test/membership/member_fuzzy_search_linking_test.exs create mode 100644 test/mv_web/user_live/form_debug2_test.exs create mode 100644 test/mv_web/user_live/form_debug_test.exs create mode 100644 test/mv_web/user_live/form_member_linking_ui_test.exs 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/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/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/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/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.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 527a279..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" @@ -161,7 +161,7 @@ msgstr "Mitglied speichern" #: 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..." @@ -255,7 +255,7 @@ msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt" #: 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" @@ -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" @@ -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" @@ -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" @@ -659,4 +663,54 @@ msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Date #: lib/mv_web/live/custom_field_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Auto-generated identifier (immutable)" -msgstr "Automatisch generierter Identifier" +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 6035e4a..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 "" @@ -162,7 +162,7 @@ msgstr "" #: 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 "" @@ -256,7 +256,7 @@ msgstr "" #: 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 "" @@ -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 "" @@ -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" @@ -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" @@ -661,3 +665,48 @@ msgstr "" #, 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 cbc0a5d..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 "" @@ -162,7 +162,7 @@ msgstr "" #: 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 "" @@ -256,7 +256,7 @@ msgstr "" #: 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 "" @@ -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 "" @@ -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" @@ -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" @@ -661,3 +665,53 @@ msgstr "" #, 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/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/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