Compare commits
30 commits
82e2fbb109
...
55fb845855
| Author | SHA1 | Date | |
|---|---|---|---|
| 55fb845855 | |||
| 918b02a714 | |||
| d02461f8ea | |||
| 5ce220862f | |||
| 4ba03821a2 | |||
| 527657d37b | |||
| 87e54cb13f | |||
| 293e85334f | |||
| 4f3d0c21a8 | |||
| 87c5db020d | |||
| 7375b83167 | |||
| c416d0fb91 | |||
| 150bba2ef8 | |||
| 6922086fa1 | |||
| 1805916359 | |||
| 8fd981806e | |||
| a4ed2498e7 | |||
| 92e3e50d49 | |||
| 3852655597 | |||
| 7305c63130 | |||
| 56516d78b6 | |||
| 44f88f1ddd | |||
| a69ccf0ff9 | |||
| 0c75776915 | |||
| 3481b9dadf | |||
| 5e51f99797 | |||
| 5406318e8d | |||
| f6bfeadb7b | |||
| c7c6d329fb | |||
| e920d6b39c |
58 changed files with 9960 additions and 213 deletions
|
|
@ -158,11 +158,11 @@
|
|||
{Credo.Check.Warning.UnusedRegexOperation, []},
|
||||
{Credo.Check.Warning.UnusedStringOperation, []},
|
||||
{Credo.Check.Warning.UnusedTupleOperation, []},
|
||||
{Credo.Check.Warning.WrongTestFileExtension, []}
|
||||
{Credo.Check.Warning.WrongTestFileExtension, []},
|
||||
# Module documentation check (enabled after adding @moduledoc to all modules)
|
||||
{Credo.Check.Readability.ModuleDoc, []}
|
||||
],
|
||||
disabled: [
|
||||
# Checks disabled by the Mitgliederverwaltung Team
|
||||
{Credo.Check.Readability.ModuleDoc, []},
|
||||
#
|
||||
# Checks scheduled for next check update (opt-in for now)
|
||||
{Credo.Check.Refactor.UtcNowTruncate, []},
|
||||
|
|
|
|||
2578
CODE_GUIDELINES.md
Normal file
2578
CODE_GUIDELINES.md
Normal file
File diff suppressed because it is too large
Load diff
38
README.md
38
README.md
|
|
@ -161,27 +161,33 @@ Now you can log in to Mila via OIDC!
|
|||
|
||||
## 🏗️ Architecture
|
||||
|
||||
- **Backend:** Elixir, Phoenix, LiveView, Ash Framework
|
||||
- **Frontend:** Phoenix LiveView + DaisyUI + Heroicons
|
||||
- **Database:** PostgreSQL (via AshPostgres)
|
||||
- **Auth:** AshAuthentication (OIDC + password strategy)
|
||||
- **Mail:** Swoosh
|
||||
- **i18n:** Gettext
|
||||
**Tech Stack Overview:**
|
||||
- **Backend:** Elixir + Phoenix + Ash Framework
|
||||
- **Frontend:** Phoenix LiveView + Tailwind CSS + DaisyUI
|
||||
- **Database:** PostgreSQL
|
||||
- **Auth:** AshAuthentication (OIDC + password)
|
||||
|
||||
Code structure:
|
||||
- `lib/mv/` — core Ash resources/domains (`Accounts`, `Membership`)
|
||||
**Code Structure:**
|
||||
- `lib/accounts/` & `lib/membership/` — Ash resources and domains
|
||||
- `lib/mv_web/` — Phoenix controllers, LiveViews, components
|
||||
- `assets/` — frontend assets (Tailwind, JS, etc.)
|
||||
- `assets/` — Tailwind, JavaScript, static files
|
||||
|
||||
📚 **Full tech stack details:** See [`CODE_GUIDELINES.md`](CODE_GUIDELINES.md)
|
||||
📖 **Implementation history:** See [`docs/development-progress-log.md`](docs/development-progress-log.md)
|
||||
🗄️ **Database schema:** See [`docs/database-schema-readme.md`](docs/database-schema-readme.md)
|
||||
|
||||
## 🧑💻 Development
|
||||
|
||||
Useful `just` commands:
|
||||
- `just run` — start DB, Mailcrab, Rauthy, app
|
||||
- `just test` — run tests
|
||||
- `just lint` — run code style checks (credo, formatter)
|
||||
- `just audit` — run security audits
|
||||
- `just reset-database` — reset local DB
|
||||
- `just regen-migrations <name>` — regenerate migrations
|
||||
**Common commands:**
|
||||
```bash
|
||||
just run # Start full dev environment
|
||||
just test # Run test suite
|
||||
just lint # Code style checks
|
||||
just audit # Security audits
|
||||
just reset-database # Reset local DB
|
||||
```
|
||||
|
||||
📚 **Full development guidelines:** See [`CODE_GUIDELINES.md`](CODE_GUIDELINES.md)
|
||||
|
||||
## 📦 Production Deployment
|
||||
|
||||
|
|
|
|||
392
docs/database-schema-readme.md
Normal file
392
docs/database-schema-readme.md
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
# Database Schema Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides a comprehensive overview of the Mila Membership Management System database schema.
|
||||
|
||||
## Quick Links
|
||||
|
||||
- **DBML File:** [`database_schema.dbml`](./database_schema.dbml)
|
||||
- **Visualize Online:**
|
||||
- [dbdiagram.io](https://dbdiagram.io) - Upload the DBML file
|
||||
- [dbdocs.io](https://dbdocs.io) - Generate interactive documentation
|
||||
|
||||
## Schema Statistics
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| **Tables** | 5 |
|
||||
| **Domains** | 2 (Accounts, Membership) |
|
||||
| **Relationships** | 3 |
|
||||
| **Indexes** | 15+ |
|
||||
| **Triggers** | 1 (Full-text search) |
|
||||
|
||||
## Tables Overview
|
||||
|
||||
### Accounts Domain
|
||||
|
||||
#### `users`
|
||||
- **Purpose:** User authentication and session management
|
||||
- **Rows (Estimated):** Low to Medium (typically 10-50% of members)
|
||||
- **Key Features:**
|
||||
- Dual authentication (Password + OIDC)
|
||||
- Optional 1:1 link to members
|
||||
- Email as source of truth when linked
|
||||
|
||||
#### `tokens`
|
||||
- **Purpose:** JWT token storage for AshAuthentication
|
||||
- **Rows (Estimated):** Medium to High (multiple tokens per user)
|
||||
- **Key Features:**
|
||||
- Token lifecycle management
|
||||
- Revocation support
|
||||
- Multiple token purposes
|
||||
|
||||
### Membership Domain
|
||||
|
||||
#### `members`
|
||||
- **Purpose:** Club member master data
|
||||
- **Rows (Estimated):** High (core entity)
|
||||
- **Key Features:**
|
||||
- Complete member profile
|
||||
- Full-text search via tsvector
|
||||
- Bidirectional email sync with users
|
||||
- Flexible address and contact data
|
||||
|
||||
#### `properties`
|
||||
- **Purpose:** Dynamic custom member attributes
|
||||
- **Rows (Estimated):** Variable (N per member)
|
||||
- **Key Features:**
|
||||
- Union type value storage (JSONB)
|
||||
- Multiple data types supported
|
||||
- One property per type per member
|
||||
|
||||
#### `property_types`
|
||||
- **Purpose:** Schema definitions for custom properties
|
||||
- **Rows (Estimated):** Low (admin-defined)
|
||||
- **Key Features:**
|
||||
- Type definitions
|
||||
- Immutable and required flags
|
||||
- Centralized property management
|
||||
|
||||
## Key Relationships
|
||||
|
||||
```
|
||||
User (0..1) ←→ (0..1) Member
|
||||
↓
|
||||
Tokens (N)
|
||||
|
||||
Member (1) → (N) Properties
|
||||
↓
|
||||
PropertyType (1)
|
||||
```
|
||||
|
||||
### Relationship Details
|
||||
|
||||
1. **User ↔ Member (Optional 1:1, both sides optional)**
|
||||
- A User can have 0 or 1 Member (`user.member_id` can be NULL)
|
||||
- A Member can have 0 or 1 User (optional `has_one` relationship)
|
||||
- Both entities can exist independently
|
||||
- Email synchronization when linked (User.email is source of truth)
|
||||
- `ON DELETE SET NULL` on user side (User preserved when Member deleted)
|
||||
|
||||
2. **Member → Properties (1:N)**
|
||||
- One member, many properties
|
||||
- `ON DELETE CASCADE` - properties deleted with member
|
||||
- Composite unique constraint (member_id, property_type_id)
|
||||
|
||||
3. **Property → PropertyType (N:1)**
|
||||
- Properties reference type definition
|
||||
- `ON DELETE RESTRICT` - cannot delete type if in use
|
||||
- Type defines data structure
|
||||
|
||||
## Important Business Rules
|
||||
|
||||
### Email Synchronization
|
||||
- **User.email** is the source of truth when linked
|
||||
- On linking: Member.email ← User.email (overwrite)
|
||||
- After linking: Changes sync bidirectionally
|
||||
- Validation prevents email conflicts
|
||||
|
||||
### Authentication Strategies
|
||||
- **Password:** Email + hashed_password
|
||||
- **OIDC:** Email + oidc_id (Rauthy provider)
|
||||
- At least one method required per user
|
||||
|
||||
### Member Constraints
|
||||
- First name and last name required (min 1 char)
|
||||
- Email unique, validated format (5-254 chars)
|
||||
- Birth date cannot be in future
|
||||
- Join date cannot be in future
|
||||
- Exit date must be after join date
|
||||
- Phone: `+?[0-9\- ]{6,20}`
|
||||
- Postal code: 5 digits
|
||||
|
||||
### Property System
|
||||
- Maximum one property per type per member
|
||||
- Value stored as union type in JSONB
|
||||
- Supported types: string, integer, boolean, date, email
|
||||
- Types can be marked as immutable or required
|
||||
|
||||
## Indexes
|
||||
|
||||
### Performance Indexes
|
||||
|
||||
**members:**
|
||||
- `search_vector` (GIN) - Full-text search
|
||||
- `email` - Email lookups
|
||||
- `last_name` - Name sorting
|
||||
- `join_date` - Date filtering
|
||||
- `paid` (partial) - Payment status queries
|
||||
|
||||
**properties:**
|
||||
- `member_id` - Member property lookups
|
||||
- `property_type_id` - Type-based queries
|
||||
- Composite `(member_id, property_type_id)` - Uniqueness
|
||||
|
||||
**tokens:**
|
||||
- `subject` - User token lookups
|
||||
- `expires_at` - Token cleanup
|
||||
- `purpose` - Purpose-based queries
|
||||
|
||||
**users:**
|
||||
- `email` (unique) - Login lookups
|
||||
- `oidc_id` (unique) - OIDC authentication
|
||||
- `member_id` (unique) - Member linkage
|
||||
|
||||
## Full-Text Search
|
||||
|
||||
### Implementation
|
||||
- **Trigger:** `members_search_vector_trigger()`
|
||||
- **Function:** Automatically updates `search_vector` on INSERT/UPDATE
|
||||
- **Index Type:** GIN (Generalized Inverted Index)
|
||||
|
||||
### Weighted Fields
|
||||
- **Weight A (highest):** first_name, last_name
|
||||
- **Weight B:** email, notes
|
||||
- **Weight C:** birth_date, phone_number, city, street, house_number, postal_code
|
||||
- **Weight D (lowest):** join_date, exit_date
|
||||
|
||||
### Usage Example
|
||||
```sql
|
||||
SELECT * FROM members
|
||||
WHERE search_vector @@ to_tsquery('simple', 'john & doe');
|
||||
```
|
||||
|
||||
## Database Extensions
|
||||
|
||||
### Required PostgreSQL Extensions
|
||||
|
||||
1. **uuid-ossp**
|
||||
- Purpose: UUID generation functions
|
||||
- Used for: `gen_random_uuid()`, `uuid_generate_v7()`
|
||||
|
||||
2. **citext**
|
||||
- Purpose: Case-insensitive text type
|
||||
- Used for: `users.email` (case-insensitive email matching)
|
||||
|
||||
### Installation
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "citext";
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Ash Migrations
|
||||
This project uses Ash Framework's migration system:
|
||||
|
||||
```bash
|
||||
# Generate new migration
|
||||
mix ash.codegen --name add_new_feature
|
||||
|
||||
# Apply migrations
|
||||
mix ash.setup
|
||||
|
||||
# Rollback migrations
|
||||
mix ash_postgres.rollback -n 1
|
||||
```
|
||||
|
||||
### Migration Files Location
|
||||
```
|
||||
priv/repo/migrations/
|
||||
├── 20250421101957_initialize_extensions_1.exs
|
||||
├── 20250528163901_initial_migration.exs
|
||||
├── 20250617090641_member_fields.exs
|
||||
├── 20250620110850_add_accounts_domain.exs
|
||||
├── 20250912085235_AddSearchVectorToMembers.exs
|
||||
├── 20250926180341_add_unique_email_to_members.exs
|
||||
└── 20251016130855_add_constraints_for_user_member_and_property.exs
|
||||
```
|
||||
|
||||
## Data Integrity
|
||||
|
||||
### Foreign Key Behaviors
|
||||
|
||||
| Relationship | On Delete | Rationale |
|
||||
|--------------|-----------|-----------|
|
||||
| `users.member_id → members.id` | SET NULL | Preserve user account when member deleted |
|
||||
| `properties.member_id → members.id` | CASCADE | Delete properties with member |
|
||||
| `properties.property_type_id → property_types.id` | RESTRICT | Prevent deletion of types in use |
|
||||
|
||||
### Validation Layers
|
||||
|
||||
1. **Database Level:**
|
||||
- CHECK constraints
|
||||
- NOT NULL constraints
|
||||
- UNIQUE indexes
|
||||
- Foreign key constraints
|
||||
|
||||
2. **Application Level (Ash):**
|
||||
- Custom validators
|
||||
- Email format validation (EctoCommons.EmailValidator)
|
||||
- Business rule validation
|
||||
- Cross-entity validation
|
||||
|
||||
3. **UI Level:**
|
||||
- Client-side form validation
|
||||
- Real-time feedback
|
||||
- Error messages
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Query Patterns
|
||||
|
||||
**High Frequency:**
|
||||
- Member search (uses GIN index on search_vector)
|
||||
- Member list with filters (uses indexes on join_date, paid)
|
||||
- User authentication (uses unique index on email/oidc_id)
|
||||
- Property lookups by member (uses index on member_id)
|
||||
|
||||
**Medium Frequency:**
|
||||
- Member CRUD operations
|
||||
- Property updates
|
||||
- Token validation
|
||||
|
||||
**Low Frequency:**
|
||||
- PropertyType management
|
||||
- User-Member linking
|
||||
- Bulk operations
|
||||
|
||||
### Optimization Tips
|
||||
|
||||
1. **Use indexes:** All critical query paths have indexes
|
||||
2. **Preload relationships:** Use Ash's `load` to avoid N+1
|
||||
3. **Pagination:** Use keyset pagination (configured by default)
|
||||
4. **Partial indexes:** `members.paid` index only non-NULL values
|
||||
5. **Search optimization:** Full-text search via tsvector, not LIKE
|
||||
|
||||
## Visualization
|
||||
|
||||
### Using dbdiagram.io
|
||||
|
||||
1. Visit [https://dbdiagram.io](https://dbdiagram.io)
|
||||
2. Click "Import" → "From file"
|
||||
3. Upload `database_schema.dbml`
|
||||
4. View interactive diagram with relationships
|
||||
|
||||
### Using dbdocs.io
|
||||
|
||||
1. Install dbdocs CLI: `npm install -g dbdocs`
|
||||
2. Generate docs: `dbdocs build database_schema.dbml`
|
||||
3. View generated documentation
|
||||
|
||||
### VS Code Extension
|
||||
|
||||
Install "DBML Language" extension to view/edit DBML files with:
|
||||
- Syntax highlighting
|
||||
- Inline documentation
|
||||
- Error checking
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Sensitive Data
|
||||
|
||||
**Encrypted:**
|
||||
- `users.hashed_password` (bcrypt)
|
||||
|
||||
**Should Not Log:**
|
||||
- hashed_password
|
||||
- tokens (jti, purpose, extra_data)
|
||||
|
||||
**Personal Data (GDPR):**
|
||||
- All member fields (name, email, birth_date, address)
|
||||
- User email
|
||||
- Token subject
|
||||
|
||||
### Access Control
|
||||
|
||||
- Implement through Ash policies
|
||||
- Row-level security considerations for future
|
||||
- Audit logging for sensitive operations
|
||||
|
||||
## Backup Recommendations
|
||||
|
||||
### Critical Tables (Priority 1)
|
||||
- `members` - Core business data
|
||||
- `users` - Authentication data
|
||||
- `property_types` - Schema definitions
|
||||
|
||||
### Important Tables (Priority 2)
|
||||
- `properties` - Member custom data
|
||||
- `tokens` - Can be regenerated but good to backup
|
||||
|
||||
### Backup Strategy
|
||||
```bash
|
||||
# Full database backup
|
||||
pg_dump -Fc mv_prod > backup_$(date +%Y%m%d).dump
|
||||
|
||||
# Restore
|
||||
pg_restore -d mv_prod backup_20251110.dump
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Database
|
||||
- Separate test database: `mv_test`
|
||||
- Sandbox mode via Ecto.Adapters.SQL.Sandbox
|
||||
- Reset between tests
|
||||
|
||||
### Seed Data
|
||||
```bash
|
||||
# Load seed data
|
||||
mix run priv/repo/seeds.exs
|
||||
```
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Potential Additions
|
||||
|
||||
1. **Audit Log Table**
|
||||
- Track changes to members
|
||||
- Compliance and history tracking
|
||||
|
||||
2. **Payment Tracking**
|
||||
- Payment history table
|
||||
- Transaction records
|
||||
- Fee calculation
|
||||
|
||||
3. **Document Storage**
|
||||
- Member documents/attachments
|
||||
- File metadata table
|
||||
|
||||
4. **Email Queue**
|
||||
- Outbound email tracking
|
||||
- Delivery status
|
||||
|
||||
5. **Roles & Permissions**
|
||||
- User roles (admin, treasurer, member)
|
||||
- Permission management
|
||||
|
||||
## Resources
|
||||
|
||||
- **Ash Framework:** [https://hexdocs.pm/ash](https://hexdocs.pm/ash)
|
||||
- **AshPostgres:** [https://hexdocs.pm/ash_postgres](https://hexdocs.pm/ash_postgres)
|
||||
- **DBML Specification:** [https://dbml.dbdiagram.io](https://dbml.dbdiagram.io)
|
||||
- **PostgreSQL Docs:** [https://www.postgresql.org/docs/](https://www.postgresql.org/docs/)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-11-10
|
||||
**Schema Version:** 1.0
|
||||
**Database:** PostgreSQL 17.6 (dev) / 16 (prod)
|
||||
|
||||
329
docs/database_schema.dbml
Normal file
329
docs/database_schema.dbml
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
// Mila - Membership Management System
|
||||
// Database Schema Documentation
|
||||
//
|
||||
// This file can be used with:
|
||||
// - https://dbdiagram.io
|
||||
// - https://dbdocs.io
|
||||
// - VS Code Extensions: "DBML Language" or "dbdiagram.io"
|
||||
//
|
||||
// Version: 1.0
|
||||
// Last Updated: 2025-11-10
|
||||
|
||||
Project mila_membership_management {
|
||||
database_type: 'PostgreSQL'
|
||||
Note: '''
|
||||
# Mila Membership Management System
|
||||
|
||||
A membership management application for small to mid-sized clubs.
|
||||
|
||||
## Key Features:
|
||||
- User authentication (OIDC + Password)
|
||||
- Member management with flexible custom properties
|
||||
- Bidirectional email synchronization between users and members
|
||||
- Full-text search capabilities
|
||||
- GDPR-compliant data management
|
||||
|
||||
## Domains:
|
||||
- **Accounts**: User authentication and session management
|
||||
- **Membership**: Club member data and custom properties
|
||||
'''
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ACCOUNTS DOMAIN
|
||||
// ============================================
|
||||
|
||||
Table users {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
|
||||
email citext [not null, unique, note: 'Email address (case-insensitive) - source of truth when linked to member']
|
||||
hashed_password text [null, note: 'Bcrypt-hashed password (null for OIDC-only users)']
|
||||
oidc_id text [null, unique, note: 'External OIDC identifier from authentication provider (e.g., Rauthy)']
|
||||
member_id uuid [null, unique, note: 'Optional 1:1 link to member record']
|
||||
|
||||
indexes {
|
||||
email [unique, name: 'users_unique_email_index']
|
||||
oidc_id [unique, name: 'users_unique_oidc_id_index']
|
||||
member_id [unique, name: 'users_unique_member_index']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**User Authentication Table**
|
||||
|
||||
Handles user login accounts with two authentication strategies:
|
||||
1. Password-based authentication (email + hashed_password)
|
||||
2. OIDC/SSO authentication (email + oidc_id)
|
||||
|
||||
**Relationship with Members:**
|
||||
- Optional 1:1 relationship with members table (0..1 ↔ 0..1)
|
||||
- A user can have 0 or 1 member (user.member_id can be NULL)
|
||||
- A member can have 0 or 1 user (optional has_one relationship)
|
||||
- Both entities can exist independently
|
||||
- When linked, user.email is the source of truth
|
||||
- Email changes sync bidirectionally between user ↔ member
|
||||
|
||||
**Constraints:**
|
||||
- At least one auth method required (password OR oidc_id)
|
||||
- Email must be unique across all users
|
||||
- OIDC ID must be unique if present
|
||||
- Member can only be linked to one user (enforced by unique index)
|
||||
|
||||
**Deletion Behavior:**
|
||||
- When member is deleted → user.member_id set to NULL (user preserved)
|
||||
- When user is deleted → member.user relationship cleared (member preserved)
|
||||
'''
|
||||
}
|
||||
|
||||
Table tokens {
|
||||
jti text [pk, not null, note: 'JWT ID - unique token identifier']
|
||||
subject text [not null, note: 'Token subject (usually user ID)']
|
||||
purpose text [not null, note: 'Token purpose (e.g., "access", "refresh", "password_reset")']
|
||||
expires_at timestamp [not null, note: 'Token expiration timestamp (UTC)']
|
||||
extra_data jsonb [null, note: 'Additional token metadata']
|
||||
created_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Creation timestamp (UTC)']
|
||||
updated_at timestamp [not null, default: `now() AT TIME ZONE 'utc'`, note: 'Last update timestamp (UTC)']
|
||||
|
||||
indexes {
|
||||
subject [name: 'tokens_subject_idx', note: 'For user token lookups']
|
||||
expires_at [name: 'tokens_expires_at_idx', note: 'For token cleanup queries']
|
||||
purpose [name: 'tokens_purpose_idx', note: 'For purpose-based queries']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**AshAuthentication Token Management**
|
||||
|
||||
Stores JWT tokens for authentication and authorization.
|
||||
|
||||
**Token Purposes:**
|
||||
- `access`: Short-lived access tokens for API requests
|
||||
- `refresh`: Long-lived tokens for obtaining new access tokens
|
||||
- `password_reset`: Temporary tokens for password reset flow
|
||||
- `email_confirmation`: Temporary tokens for email verification
|
||||
|
||||
**Token Lifecycle:**
|
||||
- Tokens are created during login/registration
|
||||
- Can be revoked by deleting the record
|
||||
- Expired tokens should be cleaned up periodically
|
||||
- `store_all_tokens? true` enables token tracking
|
||||
'''
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MEMBERSHIP DOMAIN
|
||||
// ============================================
|
||||
|
||||
Table members {
|
||||
id uuid [pk, not null, default: `uuid_generate_v7()`, note: 'UUIDv7 primary key (sortable by creation time)']
|
||||
first_name text [not null, note: 'Member first name (min length: 1)']
|
||||
last_name text [not null, note: 'Member last name (min length: 1)']
|
||||
email text [not null, unique, note: 'Member email address (5-254 chars, validated)']
|
||||
birth_date date [null, note: 'Date of birth (cannot be in future)']
|
||||
paid boolean [null, note: 'Payment status flag']
|
||||
phone_number text [null, note: 'Contact phone number (format: +?[0-9\- ]{6,20})']
|
||||
join_date date [null, note: 'Date when member joined club (cannot be in future)']
|
||||
exit_date date [null, note: 'Date when member left club (must be after join_date)']
|
||||
notes text [null, note: 'Additional notes about member']
|
||||
city text [null, note: 'City of residence']
|
||||
street text [null, note: 'Street name']
|
||||
house_number text [null, note: 'House number']
|
||||
postal_code text [null, note: '5-digit German postal code']
|
||||
search_vector tsvector [null, note: 'Full-text search index (auto-generated)']
|
||||
|
||||
indexes {
|
||||
email [unique, name: 'members_unique_email_index']
|
||||
search_vector [type: gin, name: 'members_search_vector_idx', note: 'GIN index for full-text search']
|
||||
email [name: 'members_email_idx']
|
||||
last_name [name: 'members_last_name_idx', note: 'For name sorting']
|
||||
join_date [name: 'members_join_date_idx', note: 'For date filters']
|
||||
(paid) [name: 'members_paid_idx', type: btree, note: 'Partial index WHERE paid IS NOT NULL']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**Club Member Master Data**
|
||||
|
||||
Core entity for membership management containing:
|
||||
- Personal information (name, birth date, email)
|
||||
- Contact details (phone, address)
|
||||
- Membership status (join/exit dates, payment status)
|
||||
- Additional notes
|
||||
|
||||
**Email Synchronization:**
|
||||
When a member is linked to a user:
|
||||
- User.email is the source of truth (overwrites member.email on link)
|
||||
- Subsequent changes to either email sync bidirectionally
|
||||
- Validates that email is not already used by another unlinked user
|
||||
|
||||
**Full-Text Search:**
|
||||
- `search_vector` is auto-updated via trigger
|
||||
- Weighted fields: first_name (A), last_name (A), email (B), notes (B)
|
||||
- Supports flexible member search across multiple fields
|
||||
|
||||
**Relationships:**
|
||||
- Optional 1:1 with users (0..1 ↔ 0..1) - authentication account
|
||||
- 1:N with properties (custom dynamic fields)
|
||||
|
||||
**Validation Rules:**
|
||||
- first_name, last_name: min 1 character
|
||||
- email: 5-254 characters, valid email format
|
||||
- birth_date: cannot be in future
|
||||
- join_date: cannot be in future
|
||||
- exit_date: must be after join_date (if both present)
|
||||
- phone_number: matches pattern ^\+?[0-9\- ]{6,20}$
|
||||
- postal_code: exactly 5 digits
|
||||
'''
|
||||
}
|
||||
|
||||
Table properties {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
|
||||
value jsonb [null, note: 'Union type value storage (format: {type: "string", value: "example"})']
|
||||
member_id uuid [not null, note: 'Link to member']
|
||||
property_type_id uuid [not null, note: 'Link to property type definition']
|
||||
|
||||
indexes {
|
||||
(member_id, property_type_id) [unique, name: 'properties_unique_property_per_member_index', note: 'One property per type per member']
|
||||
member_id [name: 'properties_member_id_idx']
|
||||
property_type_id [name: 'properties_property_type_id_idx']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**Dynamic Custom Member Properties**
|
||||
|
||||
Provides flexible, extensible attributes for members beyond the fixed schema.
|
||||
|
||||
**Value Storage:**
|
||||
- Stored as JSONB map with type discrimination
|
||||
- Format: `{type: "string|integer|boolean|date|email", value: <actual_value>}`
|
||||
- Allows multiple data types in single column
|
||||
|
||||
**Supported Types:**
|
||||
- `string`: Text data
|
||||
- `integer`: Numeric data
|
||||
- `boolean`: True/False flags
|
||||
- `date`: Date values
|
||||
- `email`: Validated email addresses
|
||||
|
||||
**Constraints:**
|
||||
- Each member can have only ONE property per property_type
|
||||
- Properties are deleted when member is deleted (CASCADE)
|
||||
- Property type cannot be deleted if properties exist (RESTRICT)
|
||||
|
||||
**Use Cases:**
|
||||
- Custom membership numbers
|
||||
- Additional contact methods
|
||||
- Club-specific attributes
|
||||
- Flexible data model without schema migrations
|
||||
'''
|
||||
}
|
||||
|
||||
Table property_types {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`, note: 'Primary identifier']
|
||||
name text [not null, unique, note: 'Property name/identifier (e.g., "membership_number")']
|
||||
value_type text [not null, note: 'Data type: string | integer | boolean | date | email']
|
||||
description text [null, note: 'Human-readable description']
|
||||
immutable boolean [not null, default: false, note: 'If true, value cannot be changed after creation']
|
||||
required boolean [not null, default: false, note: 'If true, all members must have this property']
|
||||
|
||||
indexes {
|
||||
name [unique, name: 'property_types_unique_name_index']
|
||||
}
|
||||
|
||||
Note: '''
|
||||
**Property Type Definitions**
|
||||
|
||||
Defines the schema and behavior for custom member properties.
|
||||
|
||||
**Attributes:**
|
||||
- `name`: Unique identifier for the property type
|
||||
- `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 property
|
||||
|
||||
**Constraints:**
|
||||
- `value_type` must be one of: string, integer, boolean, date, email
|
||||
- `name` must be unique across all property types
|
||||
- Cannot be deleted if properties 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)
|
||||
'''
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RELATIONSHIPS
|
||||
// ============================================
|
||||
|
||||
// Optional 1:1 User ↔ Member Link
|
||||
// - A user can have 0 or 1 linked member (optional)
|
||||
// - A member can have 0 or 1 linked user (optional)
|
||||
// - Both can exist independently
|
||||
// - ON DELETE SET NULL: User preserved when member deleted
|
||||
// - Email Synchronization: When linking occurs, user.email becomes source of truth
|
||||
Ref: users.member_id - members.id [delete: set null]
|
||||
|
||||
// Member → Properties (1:N)
|
||||
// - One member can have multiple properties
|
||||
// - Each property belongs to exactly one member
|
||||
// - ON DELETE CASCADE: Properties deleted when member deleted
|
||||
// - UNIQUE constraint: One property per type per member
|
||||
Ref: properties.member_id > members.id [delete: cascade]
|
||||
|
||||
// Property → PropertyType (N:1)
|
||||
// - Many properties can reference one property type
|
||||
// - Property type defines the schema/behavior
|
||||
// - ON DELETE RESTRICT: Cannot delete type if properties exist
|
||||
Ref: properties.property_type_id > property_types.id [delete: restrict]
|
||||
|
||||
// ============================================
|
||||
// ENUMS
|
||||
// ============================================
|
||||
|
||||
// Valid data types for property values
|
||||
// Determines how Property.value is interpreted
|
||||
Enum property_value_type {
|
||||
string [note: 'Text data']
|
||||
integer [note: 'Numeric data']
|
||||
boolean [note: 'True/False flags']
|
||||
date [note: 'Date values']
|
||||
email [note: 'Validated email addresses']
|
||||
}
|
||||
|
||||
// Token purposes for different authentication flows
|
||||
Enum token_purpose {
|
||||
access [note: 'Short-lived access tokens']
|
||||
refresh [note: 'Long-lived refresh tokens']
|
||||
password_reset [note: 'Password reset tokens']
|
||||
email_confirmation [note: 'Email verification tokens']
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TABLE GROUPS
|
||||
// ============================================
|
||||
|
||||
TableGroup accounts_domain {
|
||||
users
|
||||
tokens
|
||||
|
||||
Note: '''
|
||||
**Accounts Domain**
|
||||
|
||||
Handles user authentication and session management using AshAuthentication.
|
||||
Supports multiple authentication strategies (Password, OIDC).
|
||||
'''
|
||||
}
|
||||
|
||||
TableGroup membership_domain {
|
||||
members
|
||||
properties
|
||||
property_types
|
||||
|
||||
Note: '''
|
||||
**Membership Domain**
|
||||
|
||||
Core business logic for club membership management.
|
||||
Supports flexible, extensible member data model.
|
||||
'''
|
||||
}
|
||||
|
||||
1227
docs/development-progress-log.md
Normal file
1227
docs/development-progress-log.md
Normal file
File diff suppressed because it is too large
Load diff
743
docs/feature-roadmap.md
Normal file
743
docs/feature-roadmap.md
Normal file
|
|
@ -0,0 +1,743 @@
|
|||
# Feature Roadmap & Implementation Plan
|
||||
|
||||
**Project:** Mila - Membership Management System
|
||||
**Last Updated:** 2025-11-10
|
||||
**Status:** Planning Phase
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Phase 1: Feature Area Breakdown](#phase-1-feature-area-breakdown)
|
||||
2. [Phase 2: API Endpoint Definition](#phase-2-api-endpoint-definition)
|
||||
3. [Phase 3: Implementation Task Creation](#phase-3-implementation-task-creation)
|
||||
4. [Phase 4: Task Organization and Prioritization](#phase-4-task-organization-and-prioritization)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Feature Area Breakdown
|
||||
|
||||
### Feature Areas
|
||||
|
||||
#### 1. **Authentication & Authorization** 🔐
|
||||
|
||||
**Current State:**
|
||||
- ✅ OIDC authentication (Rauthy)
|
||||
- ✅ Password-based authentication
|
||||
- ✅ User sessions and tokens
|
||||
- ✅ Basic authentication flows
|
||||
|
||||
**Open Issues:**
|
||||
- [#171](https://git.local-it.org/local-it/mitgliederverwaltung/issues/171) - Ensure correct handling of Password login vs OIDC login (M)
|
||||
- [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen (Low)
|
||||
- [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen (Low)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Role-based access control (RBAC)
|
||||
- ❌ Permission system
|
||||
- ❌ Password reset flow
|
||||
- ❌ Email verification
|
||||
- ❌ Two-factor authentication (future)
|
||||
|
||||
**Related Issues:**
|
||||
- [#191](https://git.local-it.org/local-it/mitgliederverwaltung/issues/191) - Implement Roles in Ash (M)
|
||||
- [#190](https://git.local-it.org/local-it/mitgliederverwaltung/issues/190) - Implement Permissions in Ash (M)
|
||||
- [#151](https://git.local-it.org/local-it/mitgliederverwaltung/issues/151) - Define implementation plan for roles and permissions (M) [3/7 tasks done]
|
||||
|
||||
---
|
||||
|
||||
#### 2. **Member Management** 👥
|
||||
|
||||
**Current State:**
|
||||
- ✅ Member CRUD operations
|
||||
- ✅ Member profile with personal data
|
||||
- ✅ Address management
|
||||
- ✅ Membership status tracking
|
||||
- ✅ Full-text search (PostgreSQL tsvector)
|
||||
- ✅ Sorting by basic fields
|
||||
- ✅ User-Member linking (optional 1:1)
|
||||
- ✅ Email synchronization between User and Member
|
||||
|
||||
**Open Issues:**
|
||||
- [#169](https://git.local-it.org/local-it/mitgliederverwaltung/issues/169) - Allow combined creation of Users/Members (M, Low priority)
|
||||
- [#168](https://git.local-it.org/local-it/mitgliederverwaltung/issues/168) - Allow user-member association in edit/create views (M, High priority)
|
||||
- [#165](https://git.local-it.org/local-it/mitgliederverwaltung/issues/165) - Pagination for list of members (S, Low priority)
|
||||
- [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Implement fuzzy and substring search (M, Medium priority)
|
||||
- [#160](https://git.local-it.org/local-it/mitgliederverwaltung/issues/160) - Implement clear icon in searchbar (S, Low priority)
|
||||
- [#154](https://git.local-it.org/local-it/mitgliederverwaltung/issues/154) - Concept advanced search (Low priority, needs refinement)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Fuzzy search
|
||||
- ❌ Advanced filters (date ranges, multiple criteria)
|
||||
- ❌ Pagination (currently all members loaded)
|
||||
- ❌ Bulk operations (bulk delete, bulk update)
|
||||
- ❌ Member import/export (CSV, Excel)
|
||||
- ❌ Member profile photos/avatars
|
||||
- ❌ Member history/audit log
|
||||
- ❌ Duplicate detection
|
||||
|
||||
---
|
||||
|
||||
#### 3. **Custom Fields (Property System)** 🔧
|
||||
|
||||
**Current State:**
|
||||
- ✅ Property types (string, integer, boolean, date, email)
|
||||
- ✅ Property type management
|
||||
- ✅ Dynamic property assignment to members
|
||||
- ✅ Union type storage (JSONB)
|
||||
|
||||
**Open Issues:**
|
||||
- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S) [0/3 tasks]
|
||||
- [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks]
|
||||
- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Don't show birthday field for default configurations (S, Low priority)
|
||||
- [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Default field visibility configuration
|
||||
- ❌ Field groups/categories
|
||||
- ❌ Conditional fields (show field X if field Y = value)
|
||||
- ❌ Field validation rules (min/max, regex patterns)
|
||||
- ❌ Required custom fields
|
||||
- ❌ Multi-select fields
|
||||
- ❌ File upload fields
|
||||
- ❌ Sorting by custom fields
|
||||
- ❌ Searching by custom fields
|
||||
|
||||
---
|
||||
|
||||
#### 4. **User Management** 👤
|
||||
|
||||
**Current State:**
|
||||
- ✅ User CRUD operations
|
||||
- ✅ User list view
|
||||
- ✅ User profile view
|
||||
- ✅ Admin password setting
|
||||
- ✅ User-Member relationship
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ User roles assignment UI
|
||||
- ❌ User permissions management
|
||||
- ❌ User activity log
|
||||
- ❌ User invitation system
|
||||
- ❌ User onboarding flow
|
||||
- ❌ Self-service profile editing
|
||||
- ❌ Password change flow
|
||||
|
||||
---
|
||||
|
||||
#### 5. **Navigation & UX** 🧭
|
||||
|
||||
**Current State:**
|
||||
- ✅ Basic navigation structure
|
||||
- ✅ Navbar with profile button
|
||||
- ✅ Member list as landing page
|
||||
- ✅ Breadcrumbs (basic)
|
||||
|
||||
**Open Issues:**
|
||||
- [#188](https://git.local-it.org/local-it/mitgliederverwaltung/issues/188) - Check if searching just on typing is accessible (S, Low priority)
|
||||
- [#174](https://git.local-it.org/local-it/mitgliederverwaltung/issues/174) - Accessibility - aria-sort in tables (S, Low priority)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Dashboard/Home page
|
||||
- ❌ Quick actions menu
|
||||
- ❌ Recent activity widget
|
||||
- ❌ Keyboard shortcuts
|
||||
- ❌ Mobile navigation
|
||||
- ❌ Context-sensitive help
|
||||
- ❌ Onboarding tooltips
|
||||
|
||||
---
|
||||
|
||||
#### 6. **Internationalization (i18n)** 🌍
|
||||
|
||||
**Current State:**
|
||||
- ✅ Gettext integration
|
||||
- ✅ German translations
|
||||
- ✅ English translations
|
||||
- ✅ Translation files for auth, errors, default
|
||||
|
||||
**Open Issues:**
|
||||
- [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen (Low)
|
||||
- [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen (Low)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Language switcher UI
|
||||
- ❌ User-specific language preferences
|
||||
- ❌ Date/time localization
|
||||
- ❌ Number formatting (currency, decimals)
|
||||
- ❌ Complete translation coverage
|
||||
- ❌ RTL support (future)
|
||||
|
||||
---
|
||||
|
||||
#### 7. **Payment & Fees Management** 💰
|
||||
|
||||
**Current State:**
|
||||
- ✅ Basic "paid" boolean field on members
|
||||
- ⚠️ No payment tracking
|
||||
|
||||
**Open Issues:**
|
||||
- [#156](https://git.local-it.org/local-it/mitgliederverwaltung/issues/156) - Set up & document testing environment for vereinfacht.digital (L, Low priority)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Membership fee configuration
|
||||
- ❌ Payment records/transactions
|
||||
- ❌ Payment history per member
|
||||
- ❌ Payment reminders
|
||||
- ❌ Payment status tracking (pending, paid, overdue)
|
||||
- ❌ Invoice generation
|
||||
- ❌ vereinfacht.digital API integration
|
||||
- ❌ SEPA direct debit support
|
||||
- ❌ Payment reports
|
||||
|
||||
**Related Milestones:**
|
||||
- Import transactions via vereinfacht API
|
||||
|
||||
---
|
||||
|
||||
#### 8. **Admin Panel & Configuration** ⚙️
|
||||
|
||||
**Current State:**
|
||||
- ✅ AshAdmin integration (basic)
|
||||
- ⚠️ No user-facing admin UI
|
||||
|
||||
**Open Issues:**
|
||||
- [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Global settings management
|
||||
- ❌ Club/Organization profile
|
||||
- ❌ Email templates configuration
|
||||
- ❌ Property type management UI (user-facing)
|
||||
- ❌ Role and permission management UI
|
||||
- ❌ System health dashboard
|
||||
- ❌ Audit log viewer
|
||||
- ❌ Backup/restore functionality
|
||||
|
||||
**Related Milestones:**
|
||||
- As Admin I can configure settings globally
|
||||
|
||||
---
|
||||
|
||||
#### 9. **Communication & Notifications** 📧
|
||||
|
||||
**Current State:**
|
||||
- ✅ Swoosh mailer integration
|
||||
- ✅ Email confirmation (via AshAuthentication)
|
||||
- ✅ Password reset emails (via AshAuthentication)
|
||||
- ⚠️ No member communication features
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Email broadcast to members
|
||||
- ❌ Email templates (customizable)
|
||||
- ❌ Email to member groups/filters
|
||||
|
||||
---
|
||||
|
||||
#### 10. **Reporting & Analytics** 📊
|
||||
|
||||
**Current State:**
|
||||
- ❌ No reporting features
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Member statistics dashboard
|
||||
- ❌ Membership growth charts
|
||||
- ❌ Payment reports
|
||||
- ❌ Custom report builder
|
||||
- ❌ Export to PDF/CSV/Excel
|
||||
- ❌ Scheduled reports
|
||||
- ❌ Data visualization
|
||||
|
||||
---
|
||||
|
||||
#### 11. **Data Import/Export** 📥📤
|
||||
|
||||
**Current State:**
|
||||
- ✅ Seed data script
|
||||
- ⚠️ No user-facing import/export
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ CSV import for members
|
||||
- ❌ Excel import for members
|
||||
- ❌ Import validation and preview
|
||||
- ❌ Import error handling
|
||||
- ❌ Bulk data export
|
||||
- ❌ Backup export
|
||||
- ❌ Data migration tools
|
||||
|
||||
---
|
||||
|
||||
#### 12. **Testing & Quality Assurance** 🧪
|
||||
|
||||
**Current State:**
|
||||
- ✅ ExUnit test suite
|
||||
- ✅ Unit tests for resources
|
||||
- ✅ Integration tests for email sync
|
||||
- ✅ LiveView tests
|
||||
- ✅ Component tests
|
||||
- ✅ CI/CD pipeline (Drone)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ E2E tests (browser automation)
|
||||
- ❌ Performance testing
|
||||
- ❌ Load testing
|
||||
- ❌ Security penetration testing
|
||||
- ❌ Accessibility testing automation
|
||||
- ❌ Visual regression testing
|
||||
- ❌ Test coverage reporting
|
||||
|
||||
---
|
||||
|
||||
#### 13. **Infrastructure & DevOps** 🚀
|
||||
|
||||
**Current State:**
|
||||
- ✅ Docker Compose for development
|
||||
- ✅ Production Dockerfile
|
||||
- ✅ Drone CI/CD pipeline
|
||||
- ✅ Renovate for dependency updates
|
||||
- ⚠️ No staging environment
|
||||
|
||||
**Open Issues:**
|
||||
- [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Staging environment
|
||||
- ❌ Automated deployment
|
||||
- ❌ Database backup automation
|
||||
- ❌ Monitoring and alerting
|
||||
- ❌ Error tracking (Sentry, etc.)
|
||||
- ❌ Log aggregation
|
||||
- ❌ Health checks and uptime monitoring
|
||||
|
||||
**Related Milestones:**
|
||||
- We have a staging environment
|
||||
- We implement security measures
|
||||
|
||||
---
|
||||
|
||||
#### 14. **Security & Compliance** 🔒
|
||||
|
||||
**Current State:**
|
||||
- ✅ OIDC authentication
|
||||
- ✅ Password hashing (bcrypt)
|
||||
- ✅ CSRF protection
|
||||
- ✅ SQL injection prevention (Ecto)
|
||||
- ✅ Sobelow security scans
|
||||
- ✅ Dependency auditing
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Role-based access control (see #1)
|
||||
- ❌ Audit logging
|
||||
- ❌ GDPR compliance features (data export, deletion)
|
||||
- ❌ Session management (timeout, concurrent sessions)
|
||||
- ❌ Rate limiting
|
||||
- ❌ IP whitelisting/blacklisting
|
||||
- ❌ Security headers configuration
|
||||
- ❌ Data retention policies
|
||||
|
||||
**Related Milestones:**
|
||||
- We implement security measures
|
||||
|
||||
---
|
||||
|
||||
#### 15. **Accessibility & Usability** ♿
|
||||
|
||||
**Current State:**
|
||||
- ✅ Semantic HTML
|
||||
- ✅ Basic ARIA labels
|
||||
- ⚠️ Needs comprehensive audit
|
||||
|
||||
**Open Issues:**
|
||||
- [#188](https://git.local-it.org/local-it/mitgliederverwaltung/issues/188) - Check if searching just on typing is accessible (S, Low priority)
|
||||
- [#174](https://git.local-it.org/local-it/mitgliederverwaltung/issues/174) - Accessibility - aria-sort in tables (S, Low priority)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Comprehensive accessibility audit (WCAG 2.1 Level AA)
|
||||
- ❌ Keyboard navigation improvements
|
||||
- ❌ Screen reader optimization
|
||||
- ❌ High contrast mode
|
||||
- ❌ Font size adjustments
|
||||
- ❌ Focus management
|
||||
- ❌ Skip links
|
||||
- ❌ Error announcements
|
||||
|
||||
---
|
||||
|
||||
### Feature Area Summary
|
||||
|
||||
| Feature Area | Current Status | Priority | Complexity |
|
||||
|--------------|----------------|----------|------------|
|
||||
| **Authentication & Authorization** | 40% complete | **High** | Medium |
|
||||
| **Member Management** | 70% complete | **High** | Low-Medium |
|
||||
| **Custom Fields** | 50% complete | **High** | Medium |
|
||||
| **User Management** | 60% complete | Medium | Low |
|
||||
| **Navigation & UX** | 50% complete | Medium | Low |
|
||||
| **Internationalization** | 70% complete | Low | Low |
|
||||
| **Payment & Fees** | 5% complete | **High** | High |
|
||||
| **Admin Panel** | 20% complete | Medium | Medium |
|
||||
| **Communication** | 30% complete | Medium | Medium |
|
||||
| **Reporting** | 0% complete | Medium | Medium-High |
|
||||
| **Import/Export** | 10% complete | Low | Medium |
|
||||
| **Testing & QA** | 60% complete | Medium | Low-Medium |
|
||||
| **Infrastructure** | 70% complete | Medium | Medium |
|
||||
| **Security** | 50% complete | **High** | Medium-High |
|
||||
| **Accessibility** | 40% complete | Medium | Medium |
|
||||
|
||||
---
|
||||
|
||||
### Open Milestones (From Issues)
|
||||
|
||||
1. ✅ **Ich kann einen neuen Kontakt anlegen** (Closed)
|
||||
2. 🔄 **I can search through the list of members - fulltext** (Open) - Related: #162, #154
|
||||
3. 🔄 **I can sort the list of members for specific fields** (Open) - Related: #153
|
||||
4. 🔄 **We have a intuitive navigation structure** (Open)
|
||||
5. 🔄 **We have different roles and permissions** (Open) - Related: #191, #190, #151
|
||||
6. 🔄 **As Admin I can configure settings globally** (Open)
|
||||
7. 🔄 **Accounts & Logins** (Open) - Related: #171, #169, #168
|
||||
8. 🔄 **I can add custom fields** (Open) - Related: #194, #157, #161
|
||||
9. 🔄 **Import transactions via vereinfacht API** (Open) - Related: #156
|
||||
10. 🔄 **We have a staging environment** (Open)
|
||||
11. 🔄 **We implement security measures** (Open)
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: API Endpoint Definition
|
||||
|
||||
### Endpoint Types
|
||||
|
||||
Since this is a **Phoenix LiveView** application with **Ash Framework**, we have three types of endpoints:
|
||||
|
||||
1. **LiveView Endpoints** - Mount points and event handlers
|
||||
2. **HTTP Controller Endpoints** - Traditional REST-style endpoints
|
||||
3. **Ash Resource Actions** - Backend data layer API
|
||||
|
||||
### Authentication Requirements Legend
|
||||
|
||||
- 🔓 **Public** - No authentication required
|
||||
- 🔐 **Authenticated** - Requires valid user session
|
||||
- 👤 **User Role** - Requires specific user role
|
||||
- 🛡️ **Admin Only** - Requires admin privileges
|
||||
|
||||
---
|
||||
|
||||
### 1. Authentication & Authorization Endpoints
|
||||
|
||||
#### HTTP Controller Endpoints
|
||||
|
||||
| Method | Route | Purpose | Auth | Request | Response |
|
||||
|--------|-------|---------|------|---------|----------|
|
||||
| `GET` | `/auth/user/password/sign_in` | Show password login form | 🔓 | - | HTML form |
|
||||
| `POST` | `/auth/user/password/sign_in` | Submit password login | 🔓 | `{email, password}` | Redirect + session cookie |
|
||||
| `GET` | `/auth/user/rauthy` | Initiate OIDC flow | 🔓 | - | Redirect to Rauthy |
|
||||
| `GET` | `/auth/user/rauthy/callback` | Handle OIDC callback | 🔓 | `{code, state}` | Redirect + session cookie |
|
||||
| `POST` | `/auth/user/sign_out` | Sign out user | 🔐 | - | Redirect to login |
|
||||
| `GET` | `/auth/user/password/reset` | Show password reset form | 🔓 | - | HTML form |
|
||||
| `POST` | `/auth/user/password/reset` | Request password reset | 🔓 | `{email}` | Success message + email sent |
|
||||
| `GET` | `/auth/user/password/reset/:token` | Show reset password form | 🔓 | - | HTML form |
|
||||
| `POST` | `/auth/user/password/reset/:token` | Submit new password | 🔓 | `{password, password_confirmation}` | Redirect to login |
|
||||
|
||||
#### Ash Resource Actions
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `User` | `:sign_in_with_password` | Password authentication | 🔓 | `{email, password}` | `{:ok, user}` or `{:error, reason}` |
|
||||
| `User` | `:sign_in_with_rauthy` | OIDC authentication | 🔓 | `{oidc_id, email, user_info}` | `{:ok, user}` or `{:error, reason}` |
|
||||
| `User` | `:register_with_password` | Create user with password | 🔓 | `{email, password}` | `{:ok, user}` |
|
||||
| `User` | `:register_with_rauthy` | Create user via OIDC | 🔓 | `{oidc_id, email}` | `{:ok, user}` |
|
||||
| `User` | `:request_password_reset` | Generate reset token | 🔓 | `{email}` | `{:ok, token}` |
|
||||
| `User` | `:reset_password` | Reset password with token | 🔓 | `{token, password}` | `{:ok, user}` |
|
||||
| `Token` | `:revoke` | Revoke authentication token | 🔐 | `{jti}` | `{:ok, token}` |
|
||||
|
||||
#### **NEW: Role & Permission Actions** (Issue #191, #190, #151)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `Role` | `:create` | Create new role | 🛡️ | `{name, description, permissions}` | `{:ok, role}` |
|
||||
| `Role` | `:list` | List all roles | 🔐 | - | `[%Role{}]` |
|
||||
| `Role` | `:update` | Update role | 🛡️ | `{id, name, permissions}` | `{:ok, role}` |
|
||||
| `Role` | `:delete` | Delete role | 🛡️ | `{id}` | `{:ok, role}` |
|
||||
| `User` | `:assign_role` | Assign role to user | 🛡️ | `{user_id, role_id}` | `{:ok, user}` |
|
||||
| `User` | `:remove_role` | Remove role from user | 🛡️ | `{user_id, role_id}` | `{:ok, user}` |
|
||||
| `Permission` | `:list` | List all permissions | 🔐 | - | `[%Permission{}]` |
|
||||
| `Permission` | `:check` | Check user permission | 🔐 | `{user_id, resource, action}` | `{:ok, boolean}` |
|
||||
|
||||
---
|
||||
|
||||
### 2. Member Management Endpoints
|
||||
|
||||
#### LiveView Endpoints
|
||||
|
||||
| Mount | Purpose | Auth | Query Params | Events |
|
||||
|-------|---------|------|--------------|--------|
|
||||
| `/members` | Member list with search/sort | 🔐 | `?search=&sort_by=&sort_dir=` | `search`, `sort`, `delete`, `select` |
|
||||
| `/members/new` | Create new member form | 🔐 | - | `save`, `cancel`, `add_property` |
|
||||
| `/members/:id` | Member detail view | 🔐 | - | `edit`, `delete`, `link_user` |
|
||||
| `/members/:id/edit` | Edit member form | 🔐 | - | `save`, `cancel`, `add_property`, `remove_property` |
|
||||
|
||||
#### LiveView Event Handlers
|
||||
|
||||
| Event | Purpose | Params | Response |
|
||||
|-------|---------|--------|----------|
|
||||
| `search` | Trigger search | `%{"search" => query}` | Update member list |
|
||||
| `sort` | Sort member list | `%{"field" => field}` | Update sorted list |
|
||||
| `delete` | Delete member | `%{"id" => id}` | Redirect to list |
|
||||
| `save` | Create/update member | `%{"member" => attrs}` | Redirect or show errors |
|
||||
| `link_user` | Link user to member | `%{"user_id" => id}` | Update member view |
|
||||
| `unlink_user` | Unlink user from member | - | Update member view |
|
||||
| `add_property` | Add custom property | `%{"property_type_id" => id, "value" => val}` | Update form |
|
||||
| `remove_property` | Remove custom property | `%{"property_id" => id}` | Update form |
|
||||
|
||||
#### Ash Resource Actions
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `Member` | `:create_member` | Create member | 🔐 | `{first_name, last_name, email, ...}` | `{:ok, member}` |
|
||||
| `Member` | `:read` | List/search members | 🔐 | `{search, sort_by, limit, offset}` | `[%Member{}]` |
|
||||
| `Member` | `:update_member` | Update member | 🔐 | `{id, attrs}` | `{:ok, member}` |
|
||||
| `Member` | `:destroy` | Delete member | 🔐 | `{id}` | `{:ok, member}` |
|
||||
| `Member` | `:search_fulltext` | Full-text search | 🔐 | `{query}` | `[%Member{}]` |
|
||||
| `Member` | `:link_to_user` | Link member to user | 🔐 | `{member_id, user_id}` | `{:ok, member}` |
|
||||
| `Member` | `:unlink_from_user` | Unlink from user | 🔐 | `{member_id}` | `{:ok, member}` |
|
||||
|
||||
#### **NEW: Enhanced Search & Filter Actions** (Issue #162, #154, #165)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `Member` | `:fuzzy_search` | Fuzzy text search | 🔐 | `{query, threshold}` | `[%Member{}]` |
|
||||
| `Member` | `:advanced_search` | Multi-criteria search | 🔐 | `{filters: [{field, op, value}]}` | `[%Member{}]` |
|
||||
| `Member` | `:paginate` | Paginated member list | 🔐 | `{page, per_page, filters}` | `{members, total, page_info}` |
|
||||
| `Member` | `:sort_by_custom_field` | Sort by property | 🔐 | `{property_type_id, direction}` | `[%Member{}]` |
|
||||
| `Member` | `:bulk_delete` | Delete multiple members | 🛡️ | `{ids: [id1, id2, ...]}` | `{:ok, count}` |
|
||||
| `Member` | `:bulk_update` | Update multiple members | 🛡️ | `{ids, attrs}` | `{:ok, count}` |
|
||||
| `Member` | `:export` | Export to CSV/Excel | 🔐 | `{format, filters}` | File download |
|
||||
| `Member` | `:import` | Import from CSV | 🛡️ | `{file, mapping}` | `{:ok, imported_count, errors}` |
|
||||
|
||||
---
|
||||
|
||||
### 3. Custom Fields (Property System) Endpoints
|
||||
|
||||
#### LiveView Endpoints
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/property-types` | List property types | 🛡️ | `new`, `edit`, `delete` |
|
||||
| `/property-types/new` | Create property type | 🛡️ | `save`, `cancel` |
|
||||
| `/property-types/:id/edit` | Edit property type | 🛡️ | `save`, `cancel`, `delete` |
|
||||
|
||||
#### Ash Resource Actions
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `PropertyType` | `:create` | Create property type | 🛡️ | `{name, value_type, description, ...}` | `{:ok, property_type}` |
|
||||
| `PropertyType` | `:read` | List property types | 🔐 | - | `[%PropertyType{}]` |
|
||||
| `PropertyType` | `:update` | Update property type | 🛡️ | `{id, attrs}` | `{:ok, property_type}` |
|
||||
| `PropertyType` | `:destroy` | Delete property type | 🛡️ | `{id}` | `{:ok, property_type}` |
|
||||
| `Property` | `:create` | Add property to member | 🔐 | `{member_id, property_type_id, value}` | `{:ok, property}` |
|
||||
| `Property` | `:update` | Update property value | 🔐 | `{id, value}` | `{:ok, property}` |
|
||||
| `Property` | `:destroy` | Remove property | 🔐 | `{id}` | `{:ok, property}` |
|
||||
|
||||
#### **NEW: Enhanced Custom Fields** (Issue #194, #157, #161, #153)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `PropertyType` | `:set_default_visibility` | Show/hide by default | 🛡️ | `{id, visible}` | `{:ok, property_type}` |
|
||||
| `PropertyType` | `:set_required` | Mark as required | 🛡️ | `{id, required}` | `{:ok, property_type}` |
|
||||
| `PropertyType` | `:add_validation` | Add validation rule | 🛡️ | `{id, rule_type, params}` | `{:ok, property_type}` |
|
||||
| `PropertyType` | `:create_group` | Create field group | 🛡️ | `{name, property_type_ids}` | `{:ok, group}` |
|
||||
| `Property` | `:validate_value` | Validate property value | 🔐 | `{property_type_id, value}` | `{:ok, valid}` or `{:error, reason}` |
|
||||
|
||||
---
|
||||
|
||||
### 4. User Management Endpoints
|
||||
|
||||
#### LiveView Endpoints
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/users` | User list | 🛡️ | `new`, `edit`, `delete`, `assign_role` |
|
||||
| `/users/new` | Create user form | 🛡️ | `save`, `cancel` |
|
||||
| `/users/:id` | User detail view | 🔐 | `edit`, `delete`, `change_password` |
|
||||
| `/users/:id/edit` | Edit user form | 🔐 | `save`, `cancel`, `link_member` |
|
||||
| `/profile` | Current user profile | 🔐 | `edit`, `change_password` |
|
||||
|
||||
#### Ash Resource Actions
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `User` | `:create_user` | Create user (admin) | 🛡️ | `{email, member_id?}` | `{:ok, user}` |
|
||||
| `User` | `:read` | List users | 🛡️ | - | `[%User{}]` |
|
||||
| `User` | `:update_user` | Update user | 🔐 | `{id, email, member_id?}` | `{:ok, user}` |
|
||||
| `User` | `:destroy` | Delete user | 🛡️ | `{id}` | `{:ok, user}` |
|
||||
| `User` | `:admin_set_password` | Set password (admin) | 🛡️ | `{id, password}` | `{:ok, user}` |
|
||||
| `User` | `:change_password` | Change own password | 🔐 | `{current_password, new_password}` | `{:ok, user}` |
|
||||
|
||||
#### **NEW: Combined User/Member Management** (Issue #169, #168)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `User` | `:create_with_member` | Create user + member together | 🛡️ | `{user: {...}, member: {...}}` | `{:ok, %{user, member}}` |
|
||||
| `User` | `:invite_user` | Send invitation email | 🛡️ | `{email, role_id, member_id?}` | `{:ok, invitation}` |
|
||||
| `User` | `:accept_invitation` | Accept invitation | 🔓 | `{token, password}` | `{:ok, user}` |
|
||||
|
||||
---
|
||||
|
||||
### 5. Navigation & UX Endpoints
|
||||
|
||||
#### LiveView Endpoints
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/` | Dashboard/Home | 🔐 | - |
|
||||
| `/dashboard` | Dashboard view | 🔐 | Contextual based on role |
|
||||
|
||||
#### HTTP Controller Endpoints
|
||||
|
||||
| Method | Route | Purpose | Auth | Request | Response |
|
||||
|--------|-------|---------|------|---------|----------|
|
||||
| `GET` | `/health` | Health check | 🔓 | - | `{"status": "ok"}` |
|
||||
| `GET` | `/` | Root redirect | - | - | Redirect to dashboard or login |
|
||||
|
||||
---
|
||||
|
||||
### 6. Internationalization Endpoints
|
||||
|
||||
#### HTTP Controller Endpoints
|
||||
|
||||
| Method | Route | Purpose | Auth | Request | Response |
|
||||
|--------|-------|---------|------|---------|----------|
|
||||
| `POST` | `/locale` | Set user locale | 🔐 | `{locale: "de"}` | Redirect with cookie |
|
||||
| `GET` | `/locales` | List available locales | 🔓 | - | `["de", "en"]` |
|
||||
|
||||
---
|
||||
|
||||
### 7. Payment & Fees Management Endpoints
|
||||
|
||||
#### LiveView Endpoints (NEW - Issue #156)
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/payments` | Payment list | 🔐 | `new`, `record_payment`, `send_reminder` |
|
||||
| `/payments/:id` | Payment detail | 🔐 | `edit`, `delete`, `mark_paid` |
|
||||
| `/fees` | Fee configuration | 🛡️ | `create`, `edit`, `delete` |
|
||||
| `/invoices` | Invoice list | 🔐 | `generate`, `download`, `send` |
|
||||
|
||||
#### Ash Resource Actions (NEW)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `Fee` | `:create` | Create fee type | 🛡️ | `{name, amount, frequency}` | `{:ok, fee}` |
|
||||
| `Fee` | `:read` | List fees | 🔐 | - | `[%Fee{}]` |
|
||||
| `Payment` | `:create` | Record payment | 🔐 | `{member_id, fee_id, amount, date}` | `{:ok, payment}` |
|
||||
| `Payment` | `:list_by_member` | Member payment history | 🔐 | `{member_id}` | `[%Payment{}]` |
|
||||
| `Payment` | `:mark_paid` | Mark as paid | 🔐 | `{id}` | `{:ok, payment}` |
|
||||
| `Invoice` | `:generate` | Generate invoice | 🔐 | `{member_id, fee_id, period}` | `{:ok, invoice}` |
|
||||
| `Invoice` | `:send` | Send invoice via email | 🔐 | `{id}` | `{:ok, sent}` |
|
||||
| `Payment` | `:import_vereinfacht` | Import from vereinfacht.digital | 🛡️ | `{transactions}` | `{:ok, count}` |
|
||||
|
||||
---
|
||||
|
||||
### 8. Admin Panel & Configuration Endpoints
|
||||
|
||||
#### LiveView Endpoints (NEW)
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/admin` | Admin dashboard | 🛡️ | - |
|
||||
| `/admin/settings` | Global settings | 🛡️ | `save` |
|
||||
| `/admin/organization` | Organization profile | 🛡️ | `save` |
|
||||
| `/admin/email-templates` | Email template editor | 🛡️ | `create`, `edit`, `preview` |
|
||||
| `/admin/audit-log` | System audit log | 🛡️ | `filter`, `export` |
|
||||
|
||||
#### Ash Resource Actions (NEW)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `Setting` | `:get` | Get setting value | 🔐 | `{key}` | `value` |
|
||||
| `Setting` | `:set` | Set setting value | 🛡️ | `{key, value}` | `{:ok, setting}` |
|
||||
| `Setting` | `:list` | List all settings | 🛡️ | - | `[%Setting{}]` |
|
||||
| `Organization` | `:read` | Get organization info | 🔐 | - | `%Organization{}` |
|
||||
| `Organization` | `:update` | Update organization | 🛡️ | `{name, logo, ...}` | `{:ok, org}` |
|
||||
| `AuditLog` | `:list` | List audit entries | 🛡️ | `{filters, pagination}` | `[%AuditLog{}]` |
|
||||
|
||||
---
|
||||
|
||||
### 9. Communication & Notifications Endpoints
|
||||
|
||||
#### LiveView Endpoints (NEW)
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/communications` | Communication history | 🔐 | `new`, `view` |
|
||||
| `/communications/new` | Create email broadcast | 🔐 | `select_recipients`, `preview`, `send` |
|
||||
| `/notifications` | User notifications | 🔐 | `mark_read`, `mark_all_read` |
|
||||
|
||||
#### Ash Resource Actions (NEW)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `EmailBroadcast` | `:create` | Create broadcast | 🔐 | `{subject, body, recipient_filter}` | `{:ok, broadcast}` |
|
||||
| `EmailBroadcast` | `:send` | Send broadcast | 🔐 | `{id}` | `{:ok, sent_count}` |
|
||||
| `EmailTemplate` | `:create` | Create template | 🛡️ | `{name, subject, body}` | `{:ok, template}` |
|
||||
| `EmailTemplate` | `:render` | Render template | 🔐 | `{id, variables}` | `rendered_html` |
|
||||
| `Notification` | `:create` | Create notification | System | `{user_id, type, message}` | `{:ok, notification}` |
|
||||
| `Notification` | `:list_for_user` | Get user notifications | 🔐 | `{user_id}` | `[%Notification{}]` |
|
||||
| `Notification` | `:mark_read` | Mark as read | 🔐 | `{id}` | `{:ok, notification}` |
|
||||
|
||||
---
|
||||
|
||||
### 10. Reporting & Analytics Endpoints
|
||||
|
||||
#### LiveView Endpoints (NEW)
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/reports` | Reports dashboard | 🔐 | `generate`, `schedule` |
|
||||
| `/reports/members` | Member statistics | 🔐 | `filter`, `export` |
|
||||
| `/reports/payments` | Payment reports | 🔐 | `filter`, `export` |
|
||||
| `/reports/custom` | Custom report builder | 🛡️ | `build`, `save`, `run` |
|
||||
|
||||
#### Ash Resource Actions (NEW)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `Report` | `:generate_member_stats` | Member statistics | 🔐 | `{date_range, filters}` | Statistics object |
|
||||
| `Report` | `:generate_payment_stats` | Payment statistics | 🔐 | `{date_range}` | Statistics object |
|
||||
| `Report` | `:export_to_csv` | Export report to CSV | 🔐 | `{report_type, filters}` | CSV file |
|
||||
| `Report` | `:export_to_pdf` | Export report to PDF | 🔐 | `{report_type, filters}` | PDF file |
|
||||
| `Report` | `:schedule` | Schedule recurring report | 🛡️ | `{report_type, frequency, recipients}` | `{:ok, schedule}` |
|
||||
|
||||
---
|
||||
|
||||
### 11. Data Import/Export Endpoints
|
||||
|
||||
#### LiveView Endpoints (NEW)
|
||||
|
||||
| Mount | Purpose | Auth | Events |
|
||||
|-------|---------|------|--------|
|
||||
| `/import` | Data import wizard | 🛡️ | `upload`, `map_fields`, `preview`, `import` |
|
||||
| `/export` | Data export tool | 🔐 | `select_data`, `configure`, `export` |
|
||||
|
||||
#### Ash Resource Actions (NEW)
|
||||
|
||||
| Resource | Action | Purpose | Auth | Input | Output |
|
||||
|----------|--------|---------|------|-------|--------|
|
||||
| `Member` | `:import_csv` | Import members from CSV | 🛡️ | `{file, field_mapping}` | `{:ok, imported, errors}` |
|
||||
| `Member` | `:validate_import` | Validate import data | 🛡️ | `{file, field_mapping}` | `{:ok, validation_results}` |
|
||||
| `Member` | `:export_csv` | Export members to CSV | 🔐 | `{filters}` | CSV file |
|
||||
| `Member` | `:export_excel` | Export members to Excel | 🔐 | `{filters}` | Excel file |
|
||||
| `Database` | `:export_backup` | Full database backup | 🛡️ | - | Backup file |
|
||||
| `Database` | `:import_backup` | Restore from backup | 🛡️ | `{file}` | `{:ok, restored}` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
---
|
||||
|
||||
**References:**
|
||||
- Open Issues: https://git.local-it.org/local-it/mitgliederverwaltung/issues
|
||||
- Project Board: Sprint 8 (23.10 - 13.11)
|
||||
- Architecture: See [`CODE_GUIDELINES.md`](../CODE_GUIDELINES.md)
|
||||
- Database Schema: See [`database-schema-readme.md`](database-schema-readme.md)
|
||||
|
||||
207
docs/oidc-account-linking.md
Normal file
207
docs/oidc-account-linking.md
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
# OIDC Account Linking Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This feature implements secure account linking between password-based accounts and OIDC authentication. When a user attempts to log in via OIDC with an email that already exists as a password-only account, the system requires password verification before linking the accounts.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Key Components
|
||||
|
||||
#### 1. Security Fix: `lib/accounts/user.ex`
|
||||
|
||||
**Change**: The `sign_in_with_rauthy` action now filters by `oidc_id` instead of `email`.
|
||||
|
||||
```elixir
|
||||
read :sign_in_with_rauthy do
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
|
||||
# SECURITY: Filter by oidc_id, NOT by email!
|
||||
filter expr(oidc_id == get_path(^arg(:user_info), [:sub]))
|
||||
end
|
||||
```
|
||||
|
||||
**Why**: Prevents OIDC users from bypassing password authentication and taking over existing accounts.
|
||||
|
||||
#### 2. Custom Error: `lib/accounts/user/errors/password_verification_required.ex`
|
||||
|
||||
Custom error raised when OIDC login conflicts with existing password account.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `user_id`: ID of the existing user
|
||||
- `oidc_user_info`: OIDC user information for account linking
|
||||
|
||||
#### 3. Validation: `lib/accounts/user/validations/oidc_email_collision.ex`
|
||||
|
||||
Validates email uniqueness during OIDC registration.
|
||||
|
||||
**Scenarios**:
|
||||
|
||||
1. **User exists with matching `oidc_id`**: Allow (upsert)
|
||||
2. **User exists without `oidc_id`** (password-protected OR passwordless): Raise `PasswordVerificationRequired`
|
||||
- The `LinkOidcAccountLive` will auto-link passwordless users without password prompt
|
||||
- Password-protected users must verify their password
|
||||
3. **User exists with different `oidc_id`**: Hard error (cannot link multiple OIDC providers)
|
||||
4. **No user exists**: Allow (new user creation)
|
||||
|
||||
#### 4. Account Linking Action: `lib/accounts/user.ex`
|
||||
|
||||
```elixir
|
||||
update :link_oidc_id do
|
||||
description "Links an OIDC ID to an existing user after password verification"
|
||||
accept []
|
||||
argument :oidc_id, :string, allow_nil?: false
|
||||
argument :oidc_user_info, :map, allow_nil?: false
|
||||
# ... implementation
|
||||
end
|
||||
```
|
||||
|
||||
**Features**:
|
||||
|
||||
- Links `oidc_id` to existing user
|
||||
- Updates email if it differs from OIDC provider
|
||||
- Syncs email changes to linked member
|
||||
|
||||
#### 5. Controller: `lib/mv_web/controllers/auth_controller.ex`
|
||||
|
||||
Refactored for better complexity and maintainability.
|
||||
|
||||
**Key improvements**:
|
||||
|
||||
- Reduced cyclomatic complexity from 11 to below 9
|
||||
- Better separation of concerns with helper functions
|
||||
- Comprehensive documentation
|
||||
|
||||
**Flow**:
|
||||
|
||||
1. Detects `PasswordVerificationRequired` error
|
||||
2. Stores OIDC info in session
|
||||
3. Redirects to account linking page
|
||||
|
||||
#### 6. LiveView: `lib/mv_web/live/auth/link_oidc_account_live.ex`
|
||||
|
||||
Interactive UI for password verification and account linking.
|
||||
|
||||
**Flow**:
|
||||
|
||||
1. Retrieves OIDC info from session
|
||||
2. **Auto-links passwordless users** immediately (no password prompt)
|
||||
3. Displays password verification form for password-protected users
|
||||
4. Verifies password using AshAuthentication
|
||||
5. Links OIDC account on success
|
||||
6. Redirects to complete OIDC login
|
||||
7. **Logs all security-relevant events** (successful/failed linking attempts)
|
||||
|
||||
### Locale Persistence
|
||||
|
||||
**Problem**: Locale was lost on logout (session cleared).
|
||||
|
||||
**Solution**: Store locale in persistent cookie (1 year TTL) with security flags.
|
||||
|
||||
**Changes**:
|
||||
|
||||
- `lib/mv_web/locale_controller.ex`: Sets locale cookie with `http_only` and `secure` flags
|
||||
- `lib/mv_web/router.ex`: Reads locale from cookie if session empty
|
||||
|
||||
**Security Features**:
|
||||
- `http_only: true` - Cookie not accessible via JavaScript (XSS protection)
|
||||
- `secure: true` - Cookie only transmitted over HTTPS in production
|
||||
- `same_site: "Lax"` - CSRF protection
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### 1. OIDC ID Matching
|
||||
|
||||
- **Before**: Matched by email (vulnerable to account takeover)
|
||||
- **After**: Matched by `oidc_id` (secure)
|
||||
|
||||
### 2. Account Linking Flow
|
||||
|
||||
- Password verification required before linking (for password-protected users)
|
||||
- Passwordless users are auto-linked immediately (secure, as they have no password)
|
||||
- OIDC info stored in session (not in URL/query params)
|
||||
- CSRF protection on all forms
|
||||
- All linking attempts logged for audit trail
|
||||
|
||||
### 3. Email Updates
|
||||
|
||||
- Email updates from OIDC provider are applied during linking
|
||||
- Email changes sync to linked member (if exists)
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
- Internal errors are logged but not exposed to users (prevents information disclosure)
|
||||
- User-friendly error messages shown in UI
|
||||
- Security-relevant events logged with appropriate levels:
|
||||
- `Logger.info` for successful operations
|
||||
- `Logger.warning` for failed authentication attempts
|
||||
- `Logger.error` for system errors
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Scenario 1: New OIDC User
|
||||
|
||||
```elixir
|
||||
# User signs in with OIDC for the first time
|
||||
# → New user created with oidc_id
|
||||
```
|
||||
|
||||
### Scenario 2: Existing OIDC User
|
||||
|
||||
```elixir
|
||||
# User with oidc_id signs in via OIDC
|
||||
# → Matched by oidc_id, email updated if changed
|
||||
```
|
||||
|
||||
### Scenario 3: Password User + OIDC Login
|
||||
|
||||
```elixir
|
||||
# User with password account tries OIDC login
|
||||
# → PasswordVerificationRequired raised
|
||||
# → Redirected to /auth/link-oidc-account
|
||||
# → User enters password
|
||||
# → Password verified and logged
|
||||
# → oidc_id linked to account
|
||||
# → Successful linking logged
|
||||
# → Redirected to complete OIDC login
|
||||
```
|
||||
|
||||
### Scenario 4: Passwordless User + OIDC Login
|
||||
|
||||
```elixir
|
||||
# User without password (invited user) tries OIDC login
|
||||
# → PasswordVerificationRequired raised
|
||||
# → Redirected to /auth/link-oidc-account
|
||||
# → System detects passwordless user
|
||||
# → oidc_id automatically linked (no password prompt)
|
||||
# → Auto-linking logged
|
||||
# → Redirected to complete OIDC login
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Custom Actions
|
||||
|
||||
#### `link_oidc_id`
|
||||
|
||||
Links an OIDC ID to existing user after password verification.
|
||||
|
||||
**Arguments**:
|
||||
|
||||
- `oidc_id` (required): OIDC sub/id from provider
|
||||
- `oidc_user_info` (required): Full OIDC user info map
|
||||
|
||||
**Returns**: Updated user with linked `oidc_id`
|
||||
|
||||
**Side Effects**:
|
||||
|
||||
- Updates email if different from OIDC provider
|
||||
- Syncs email to linked member (if exists)
|
||||
|
||||
## References
|
||||
|
||||
- [AshAuthentication Documentation](https://hexdocs.pm/ash_authentication)
|
||||
- [OIDC Specification](https://openid.net/specs/openid-connect-core-1_0.html)
|
||||
- [Security Best Practices for Account Linking](https://cheatsheetseries.owasp.org/cheatsheets/Credential_Stuffing_Prevention_Cheat_Sheet.html)
|
||||
|
|
@ -171,6 +171,41 @@ defmodule Mv.Accounts.User do
|
|||
change AshAuthentication.Strategy.Password.HashPasswordChange
|
||||
end
|
||||
|
||||
# Action to link an OIDC account to an existing password-only user
|
||||
# This is called after the user has verified their password
|
||||
update :link_oidc_id do
|
||||
description "Links an OIDC ID to an existing user after password verification"
|
||||
accept []
|
||||
argument :oidc_id, :string, allow_nil?: false
|
||||
argument :oidc_user_info, :map, allow_nil?: false
|
||||
require_atomic? false
|
||||
|
||||
change fn changeset, _ctx ->
|
||||
oidc_id = Ash.Changeset.get_argument(changeset, :oidc_id)
|
||||
oidc_user_info = Ash.Changeset.get_argument(changeset, :oidc_user_info)
|
||||
|
||||
# Get the new email from OIDC user_info
|
||||
new_email = Map.get(oidc_user_info, "preferred_username")
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.change_attribute(:oidc_id, oidc_id)
|
||||
# Update email if it differs from OIDC provider
|
||||
# change_attribute/3 already checks if value matches existing value
|
||||
|> then(fn cs ->
|
||||
if new_email do
|
||||
Ash.Changeset.change_attribute(cs, :email, new_email)
|
||||
else
|
||||
cs
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# Sync email changes to member if email was updated
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||
where [changing(:email)]
|
||||
end
|
||||
end
|
||||
|
||||
read :get_by_subject do
|
||||
description "Get a user by the subject claim in a JWT"
|
||||
argument :subject, :string, allow_nil?: false
|
||||
|
|
@ -183,13 +218,18 @@ defmodule Mv.Accounts.User do
|
|||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
prepare AshAuthentication.Strategy.OAuth2.SignInPreparation
|
||||
|
||||
filter expr(email == get_path(^arg(:user_info), [:preferred_username]))
|
||||
# SECURITY: Filter by oidc_id, NOT by email!
|
||||
# This ensures that OIDC sign-in only works for users who have already
|
||||
# linked their account via OIDC. Password-only users (oidc_id = nil)
|
||||
# cannot be accessed via OIDC login without password verification.
|
||||
filter expr(oidc_id == get_path(^arg(:user_info), [:sub]))
|
||||
end
|
||||
|
||||
create :register_with_rauthy do
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
upsert? true
|
||||
# Upsert based on oidc_id (primary match for existing OIDC users)
|
||||
upsert_identity :unique_oidc_id
|
||||
|
||||
validate &__MODULE__.validate_oidc_id_present/2
|
||||
|
|
@ -204,6 +244,12 @@ defmodule Mv.Accounts.User do
|
|||
|> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"])
|
||||
end
|
||||
|
||||
# Check for email collisions with existing accounts
|
||||
# This validation must run AFTER email and oidc_id are set above
|
||||
# - Raises PasswordVerificationRequired for password-protected OR passwordless users
|
||||
# - The LinkOidcAccountLive will auto-link passwordless users without password prompt
|
||||
validate Mv.Accounts.User.Validations.OidcEmailCollision
|
||||
|
||||
# Sync user email to member when linking (User → Member)
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
||||
end
|
||||
|
|
|
|||
33
lib/accounts/user/errors/password_verification_required.ex
Normal file
33
lib/accounts/user/errors/password_verification_required.ex
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
defmodule Mv.Accounts.User.Errors.PasswordVerificationRequired do
|
||||
@moduledoc """
|
||||
Custom error raised when an OIDC login attempts to use an email that already exists
|
||||
in the system with a password-only account (no oidc_id set).
|
||||
|
||||
This error indicates that the user must verify their password before the OIDC account
|
||||
can be linked to the existing password account.
|
||||
"""
|
||||
use Splode.Error,
|
||||
fields: [:user_id, :oidc_user_info],
|
||||
class: :invalid
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
user_id: String.t(),
|
||||
oidc_user_info: map()
|
||||
}
|
||||
|
||||
@doc """
|
||||
Returns a human-readable error message.
|
||||
|
||||
## Parameters
|
||||
- error: The error struct containing user_id and oidc_user_info
|
||||
"""
|
||||
def message(%{user_id: user_id, oidc_user_info: user_info}) do
|
||||
email = Map.get(user_info, "preferred_username", "unknown")
|
||||
oidc_id = Map.get(user_info, "sub") || Map.get(user_info, "id", "unknown")
|
||||
|
||||
"""
|
||||
Password verification required: An account with email '#{email}' already exists (user_id: #{user_id}).
|
||||
To link your OIDC account (oidc_id: #{oidc_id}) to this existing account, please verify your password.
|
||||
"""
|
||||
end
|
||||
end
|
||||
172
lib/accounts/user/validations/oidc_email_collision.ex
Normal file
172
lib/accounts/user/validations/oidc_email_collision.ex
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
||||
@moduledoc """
|
||||
Validation that checks for email collisions during OIDC registration.
|
||||
|
||||
This validation prevents unauthorized account takeovers and enforces proper
|
||||
account linking flows based on user state.
|
||||
|
||||
## Scenarios:
|
||||
|
||||
1. **User exists with matching oidc_id**:
|
||||
- Allow (upsert will update the existing user)
|
||||
|
||||
2. **User exists with different oidc_id**:
|
||||
- Hard error: Cannot link multiple OIDC providers to same account
|
||||
- No linking possible - user must use original OIDC provider
|
||||
|
||||
3. **User exists without oidc_id** (password-protected OR passwordless):
|
||||
- Raise PasswordVerificationRequired error
|
||||
- User is redirected to LinkOidcAccountLive which will:
|
||||
- Show password form if user has password
|
||||
- Auto-link immediately if user is passwordless
|
||||
|
||||
4. **No user exists with this email**:
|
||||
- Allow (new user will be created)
|
||||
"""
|
||||
use Ash.Resource.Validation
|
||||
require Logger
|
||||
|
||||
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||
|
||||
@impl true
|
||||
def init(opts), do: {:ok, opts}
|
||||
|
||||
@impl true
|
||||
def validate(changeset, _opts, _context) do
|
||||
# Get the email and oidc_id from the changeset
|
||||
email = Ash.Changeset.get_attribute(changeset, :email)
|
||||
oidc_id = Ash.Changeset.get_attribute(changeset, :oidc_id)
|
||||
user_info = Ash.Changeset.get_argument(changeset, :user_info)
|
||||
|
||||
# Only validate if we have both email and oidc_id (from OIDC registration)
|
||||
if email && oidc_id && user_info do
|
||||
# Check if a user with this oidc_id already exists
|
||||
# If yes, this will be an upsert (email update), not a new registration
|
||||
existing_oidc_user =
|
||||
case Mv.Accounts.User
|
||||
|> Ash.Query.filter(oidc_id == ^to_string(oidc_id))
|
||||
|> Ash.read_one() do
|
||||
{:ok, user} -> user
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
check_email_collision(email, oidc_id, user_info, existing_oidc_user)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user) do
|
||||
# Find existing user with this email
|
||||
case Mv.Accounts.User
|
||||
|> Ash.Query.filter(email == ^to_string(email))
|
||||
|> Ash.read_one() do
|
||||
{:ok, nil} ->
|
||||
# No user exists with this email - OK to create new user
|
||||
:ok
|
||||
|
||||
{:ok, user_with_email} ->
|
||||
# User exists with this email - check if it's an upsert or registration
|
||||
is_upsert = not is_nil(existing_oidc_user)
|
||||
|
||||
if is_upsert do
|
||||
handle_upsert_scenario(user_with_email, user_info, existing_oidc_user)
|
||||
else
|
||||
handle_create_scenario(user_with_email, new_oidc_id, user_info)
|
||||
end
|
||||
|
||||
{:error, error} ->
|
||||
# Database error - log for debugging but don't expose internals to user
|
||||
Logger.error("Email uniqueness check failed during OIDC registration: #{inspect(error)}")
|
||||
{:error, field: :email, message: "Could not verify email uniqueness. Please try again."}
|
||||
end
|
||||
end
|
||||
|
||||
# Handle email update for existing OIDC user
|
||||
defp handle_upsert_scenario(user_with_email, user_info, existing_oidc_user) do
|
||||
cond do
|
||||
# Same user updating their own record
|
||||
not is_nil(existing_oidc_user) and user_with_email.id == existing_oidc_user.id ->
|
||||
:ok
|
||||
|
||||
# Different user exists with target email
|
||||
not is_nil(existing_oidc_user) and user_with_email.id != existing_oidc_user.id ->
|
||||
handle_email_conflict(user_with_email, user_info)
|
||||
|
||||
# Should not reach here
|
||||
true ->
|
||||
{:error, field: :email, message: "Unexpected error during email update"}
|
||||
end
|
||||
end
|
||||
|
||||
# Handle email conflict during upsert
|
||||
defp handle_email_conflict(user_with_email, user_info) do
|
||||
email = Map.get(user_info, "preferred_username", "unknown")
|
||||
email_user_oidc_id = user_with_email.oidc_id
|
||||
|
||||
# Check if target email belongs to another OIDC user
|
||||
if not is_nil(email_user_oidc_id) and email_user_oidc_id != "" do
|
||||
different_oidc_error(email)
|
||||
else
|
||||
email_taken_error(email)
|
||||
end
|
||||
end
|
||||
|
||||
# Handle new OIDC user registration scenarios
|
||||
defp handle_create_scenario(user_with_email, new_oidc_id, user_info) do
|
||||
email_user_oidc_id = user_with_email.oidc_id
|
||||
|
||||
cond do
|
||||
# Same oidc_id (should not happen in practice, but allow for safety)
|
||||
email_user_oidc_id == new_oidc_id ->
|
||||
:ok
|
||||
|
||||
# Different oidc_id exists (hard error)
|
||||
not is_nil(email_user_oidc_id) and email_user_oidc_id != "" and
|
||||
email_user_oidc_id != new_oidc_id ->
|
||||
email = Map.get(user_info, "preferred_username", "unknown")
|
||||
different_oidc_error(email)
|
||||
|
||||
# No oidc_id (require account linking)
|
||||
is_nil(email_user_oidc_id) or email_user_oidc_id == "" ->
|
||||
{:error,
|
||||
PasswordVerificationRequired.exception(
|
||||
user_id: user_with_email.id,
|
||||
oidc_user_info: user_info
|
||||
)}
|
||||
|
||||
# Should not reach here
|
||||
true ->
|
||||
{:error, field: :email, message: "Unexpected error during OIDC registration"}
|
||||
end
|
||||
end
|
||||
|
||||
# Generate error for different OIDC account conflict
|
||||
defp different_oidc_error(email) do
|
||||
{:error,
|
||||
field: :email,
|
||||
message:
|
||||
"Email '#{email}' is already linked to a different OIDC account. " <>
|
||||
"Cannot link multiple OIDC providers to the same account."}
|
||||
end
|
||||
|
||||
# Generate error for email already taken
|
||||
defp email_taken_error(email) do
|
||||
{:error,
|
||||
field: :email,
|
||||
message:
|
||||
"Cannot update email to '#{email}': This email is already registered to another account. " <>
|
||||
"Please change your email in the identity provider."}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def atomic?(), do: false
|
||||
|
||||
@impl true
|
||||
def describe(_opts) do
|
||||
[
|
||||
message: "OIDC email collision detected",
|
||||
vars: []
|
||||
]
|
||||
end
|
||||
end
|
||||
|
|
@ -1,4 +1,37 @@
|
|||
defmodule Mv.Membership.Email do
|
||||
@moduledoc """
|
||||
Custom Ash type for validated email addresses.
|
||||
|
||||
## Overview
|
||||
This type extends `:string` with email-specific validation constraints.
|
||||
It ensures that email values stored in Property resources are valid email
|
||||
addresses according to a standard regex pattern.
|
||||
|
||||
## Validation Rules
|
||||
- Minimum length: 5 characters
|
||||
- Maximum length: 254 characters (RFC 5321 maximum)
|
||||
- Pattern: Standard email format (username@domain.tld)
|
||||
- Automatic trimming of leading/trailing whitespace
|
||||
|
||||
## Usage
|
||||
This type is used in the Property union type for properties with
|
||||
`value_type: :email` in PropertyType definitions.
|
||||
|
||||
## Example
|
||||
# In a property type definition
|
||||
PropertyType.create!(%{
|
||||
name: "work_email",
|
||||
value_type: :email
|
||||
})
|
||||
|
||||
# Valid values
|
||||
"user@example.com"
|
||||
"first.last@company.co.uk"
|
||||
|
||||
# Invalid values
|
||||
"not-an-email" # Missing @ and domain
|
||||
"a@b" # Too short
|
||||
"""
|
||||
@match_pattern ~S/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/
|
||||
@match_regex Regex.compile!(@match_pattern)
|
||||
@min_length 5
|
||||
|
|
|
|||
|
|
@ -1,8 +1,43 @@
|
|||
defmodule Mv.Membership.Member do
|
||||
@moduledoc """
|
||||
Ash resource representing a club member.
|
||||
|
||||
## Overview
|
||||
Members are the core entity in the membership management system. Each member
|
||||
can have:
|
||||
- Personal information (name, email, phone, address)
|
||||
- Optional link to a User account (1:1 relationship)
|
||||
- Dynamic custom properties via PropertyType system
|
||||
- Full-text searchable profile
|
||||
|
||||
## Email Synchronization
|
||||
When a member is linked to a user account, emails are automatically synchronized
|
||||
bidirectionally. User.email is the source of truth on initial link.
|
||||
See `Mv.EmailSync` for details.
|
||||
|
||||
## Relationships
|
||||
- `has_many :properties` - Dynamic custom fields
|
||||
- `has_one :user` - Optional authentication account link
|
||||
|
||||
## Validations
|
||||
- Required: first_name, last_name, email
|
||||
- Email format validation (using EctoCommons.EmailValidator)
|
||||
- Phone number format: international format with 6-20 digits
|
||||
- Postal code format: exactly 5 digits (German format)
|
||||
- Date validations: birth_date and join_date not in future, exit_date after join_date
|
||||
- Email uniqueness: prevents conflicts with unlinked users
|
||||
|
||||
## Full-Text Search
|
||||
Members have a `search_vector` attribute (tsvector) that is automatically
|
||||
updated via database trigger. Search includes name, email, notes, and contact fields.
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
postgres do
|
||||
table "members"
|
||||
repo Mv.Repo
|
||||
|
|
@ -108,6 +143,50 @@ defmodule Mv.Membership.Member do
|
|||
where [changing(:user)]
|
||||
end
|
||||
end
|
||||
|
||||
# Action to handle fuzzy search on specific fields
|
||||
read :search do
|
||||
argument :query, :string, allow_nil?: true
|
||||
argument :similarity_threshold, :float, allow_nil?: true
|
||||
|
||||
prepare fn query, _ctx ->
|
||||
q = Ash.Query.get_argument(query, :query) || ""
|
||||
|
||||
# 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
|
||||
q2 = String.trim(q)
|
||||
pat = "%" <> q2 <> "%"
|
||||
|
||||
# FTS as main filter and fuzzy search just for first name, last name and strees
|
||||
query
|
||||
|> Ash.Query.filter(
|
||||
expr(
|
||||
# Substring on numeric-like fields (best effort, supports middle substrings)
|
||||
fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^q2) or
|
||||
fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q2) or
|
||||
contains(postal_code, ^q2) or
|
||||
contains(house_number, ^q2) or
|
||||
contains(phone_number, ^q2) or
|
||||
contains(email, ^q2) or
|
||||
contains(city, ^q2) or ilike(city, ^pat) or
|
||||
fragment("? % first_name", ^q2) or
|
||||
fragment("? % last_name", ^q2) or
|
||||
fragment("? % street", ^q2) or
|
||||
fragment("word_similarity(?, first_name) > ?", ^q2, ^threshold) or
|
||||
fragment("word_similarity(?, last_name) > ?", ^q2, ^threshold) or
|
||||
fragment("word_similarity(?, street) > ?", ^q2, ^threshold) or
|
||||
fragment("similarity(first_name, ?) > ?", ^q2, ^threshold) or
|
||||
fragment("similarity(last_name, ?) > ?", ^q2, ^threshold) or
|
||||
fragment("similarity(street, ?) > ?", ^q2, ^threshold)
|
||||
)
|
||||
)
|
||||
else
|
||||
query
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
|
|
@ -281,4 +360,21 @@ defmodule Mv.Membership.Member do
|
|||
identities do
|
||||
identity :unique_email, [:email]
|
||||
end
|
||||
|
||||
# Fuzzy Search function that can be called by live view and calls search action
|
||||
def fuzzy_search(query, opts) do
|
||||
q = (opts[:query] || opts["query"] || "") |> to_string()
|
||||
|
||||
if String.trim(q) == "" do
|
||||
query
|
||||
else
|
||||
args =
|
||||
case opts[:fields] || opts["fields"] do
|
||||
nil -> %{query: q}
|
||||
fields -> %{query: q, fields: fields}
|
||||
end
|
||||
|
||||
Ash.Query.for_read(query, :search, args)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,21 @@
|
|||
defmodule Mv.Membership do
|
||||
@moduledoc """
|
||||
Ash Domain for membership management.
|
||||
|
||||
## Resources
|
||||
- `Member` - Club members with personal information and custom properties
|
||||
- `Property` - Dynamic custom field values attached to members
|
||||
- `PropertyType` - Schema definitions for custom properties
|
||||
|
||||
## Public API
|
||||
The domain exposes these main actions:
|
||||
- Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1`
|
||||
- Property management: `create_property/1`, `list_property/0`, etc.
|
||||
- PropertyType management: `create_property_type/1`, `list_property_types/0`, etc.
|
||||
|
||||
## Admin Interface
|
||||
The domain is configured with AshAdmin for management UI.
|
||||
"""
|
||||
use Ash.Domain,
|
||||
extensions: [AshAdmin.Domain, AshPhoenix]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,36 @@
|
|||
defmodule Mv.Membership.Property do
|
||||
@moduledoc """
|
||||
Ash resource representing a custom property value for a member.
|
||||
|
||||
## Overview
|
||||
Properties implement the Entity-Attribute-Value (EAV) pattern, allowing
|
||||
dynamic custom fields to be attached to members. Each property links a
|
||||
member to a property type and stores the actual value.
|
||||
|
||||
## Value Storage
|
||||
Values are stored using Ash's union type with JSONB storage format:
|
||||
```json
|
||||
{
|
||||
"type": "string",
|
||||
"value": "example"
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Types
|
||||
- `:string` - Text data
|
||||
- `:integer` - Numeric data
|
||||
- `:boolean` - True/false flags
|
||||
- `:date` - Date values
|
||||
- `:email` - Validated email addresses (custom type)
|
||||
|
||||
## Relationships
|
||||
- `belongs_to :member` - The member this property belongs to (CASCADE delete)
|
||||
- `belongs_to :property_type` - The property type definition
|
||||
|
||||
## Constraints
|
||||
- Each member can have only one property per property type (unique composite index)
|
||||
- Properties are deleted when the associated member is deleted (CASCADE)
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
|
|
|||
|
|
@ -1,4 +1,48 @@
|
|||
defmodule Mv.Membership.PropertyType do
|
||||
@moduledoc """
|
||||
Ash resource defining the schema for custom member properties.
|
||||
|
||||
## Overview
|
||||
PropertyTypes define the "schema" for custom fields in the membership system.
|
||||
Each PropertyType specifies the name, data type, and behavior of a custom field
|
||||
that can be attached to members via Property resources.
|
||||
|
||||
## Attributes
|
||||
- `name` - Unique identifier for the property (e.g., "phone_mobile", "birthday")
|
||||
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
|
||||
- `description` - Optional human-readable description
|
||||
- `immutable` - If true, property values cannot be changed after creation
|
||||
- `required` - If true, all members must have this property (future feature)
|
||||
|
||||
## Supported Value Types
|
||||
- `:string` - Text data (unlimited length)
|
||||
- `:integer` - Numeric data (64-bit integers)
|
||||
- `:boolean` - True/false flags
|
||||
- `:date` - Date values (no time component)
|
||||
- `:email` - Validated email addresses
|
||||
|
||||
## Relationships
|
||||
- `has_many :properties` - All property values of this type
|
||||
|
||||
## Constraints
|
||||
- Name must be unique across all property types
|
||||
- Cannot delete a property type that has existing property values (RESTRICT)
|
||||
|
||||
## Examples
|
||||
# Create a new property type
|
||||
PropertyType.create!(%{
|
||||
name: "phone_mobile",
|
||||
value_type: :string,
|
||||
description: "Mobile phone number"
|
||||
})
|
||||
|
||||
# Create a required property type
|
||||
PropertyType.create!(%{
|
||||
name: "emergency_contact",
|
||||
value_type: :string,
|
||||
required: true
|
||||
})
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
|
|
|||
|
|
@ -10,6 +10,20 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
|||
|
||||
alias Mv.Mailer
|
||||
|
||||
@doc """
|
||||
Sends a confirmation email to a new user.
|
||||
|
||||
This function is called automatically by AshAuthentication when a new
|
||||
user registers and needs to confirm their email address.
|
||||
|
||||
## Parameters
|
||||
- `user` - The user record who needs to confirm their email
|
||||
- `token` - The confirmation token to include in the email link
|
||||
- `_opts` - Additional options (unused)
|
||||
|
||||
## Returns
|
||||
The Swoosh.Email delivery result from `Mailer.deliver!/1`.
|
||||
"""
|
||||
@impl true
|
||||
def send(user, token, _) do
|
||||
new()
|
||||
|
|
|
|||
|
|
@ -10,6 +10,20 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
|
|||
|
||||
alias Mv.Mailer
|
||||
|
||||
@doc """
|
||||
Sends a password reset email to a user.
|
||||
|
||||
This function is called automatically by AshAuthentication when a user
|
||||
requests a password reset.
|
||||
|
||||
## Parameters
|
||||
- `user` - The user record requesting the password reset
|
||||
- `token` - The password reset token to include in the email link
|
||||
- `_opts` - Additional options (unused)
|
||||
|
||||
## Returns
|
||||
The Swoosh.Email delivery result from `Mailer.deliver!/1`.
|
||||
"""
|
||||
@impl true
|
||||
def send(user, token, _) do
|
||||
new()
|
||||
|
|
|
|||
|
|
@ -9,6 +9,22 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
|
|||
"""
|
||||
use Ash.Resource.Validation
|
||||
|
||||
@doc """
|
||||
Validates email uniqueness across linked User-Member pairs.
|
||||
|
||||
This validation ensures that when a user is linked to a member, their email
|
||||
does not conflict with another member's email. It only runs when necessary
|
||||
to avoid blocking valid operations (see `@moduledoc` for trigger conditions).
|
||||
|
||||
## Parameters
|
||||
- `changeset` - The Ash changeset being validated
|
||||
- `_opts` - Options passed to the validation (unused)
|
||||
- `_context` - Ash context map (unused)
|
||||
|
||||
## Returns
|
||||
- `:ok` if validation passes or should be skipped
|
||||
- `{:error, field: :email, message: ..., value: ...}` if validation fails
|
||||
"""
|
||||
@impl true
|
||||
def validate(changeset, _opts, _context) do
|
||||
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,21 @@ defmodule Mv.EmailSync.Changes.SyncMemberEmailToUser do
|
|||
use Ash.Resource.Change
|
||||
alias Mv.EmailSync.{Helpers, Loader}
|
||||
|
||||
@doc """
|
||||
Implements the email synchronization from Member to User.
|
||||
|
||||
This function is called automatically by Ash when the configured trigger
|
||||
conditions are met (see `@moduledoc` for trigger details).
|
||||
|
||||
## Parameters
|
||||
- `changeset` - The Ash changeset being processed
|
||||
- `_opts` - Options passed to the change (unused)
|
||||
- `context` - Ash context map containing metadata (e.g., `:syncing_email` flag)
|
||||
|
||||
## Returns
|
||||
Modified changeset with email synchronization applied, or original changeset
|
||||
if recursion detected.
|
||||
"""
|
||||
@impl true
|
||||
def change(changeset, _opts, context) do
|
||||
# Only recursion protection needed - trigger logic is in `where` clauses
|
||||
|
|
|
|||
|
|
@ -12,6 +12,21 @@ defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
|
|||
use Ash.Resource.Change
|
||||
alias Mv.EmailSync.{Helpers, Loader}
|
||||
|
||||
@doc """
|
||||
Implements the email synchronization from User to Member.
|
||||
|
||||
This function is called automatically by Ash when the configured trigger
|
||||
conditions are met (see `@moduledoc` for trigger details).
|
||||
|
||||
## Parameters
|
||||
- `changeset` - The Ash changeset being processed
|
||||
- `_opts` - Options passed to the change (unused)
|
||||
- `context` - Ash context map containing metadata (e.g., `:syncing_email` flag)
|
||||
|
||||
## Returns
|
||||
Modified changeset with email synchronization applied, or original changeset
|
||||
if recursion detected.
|
||||
"""
|
||||
@impl true
|
||||
def change(changeset, _opts, context) do
|
||||
# Only recursion protection needed - trigger logic is in `where` clauses
|
||||
|
|
|
|||
|
|
@ -9,6 +9,22 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
|||
"""
|
||||
use Ash.Resource.Validation
|
||||
|
||||
@doc """
|
||||
Validates email uniqueness across linked Member-User pairs.
|
||||
|
||||
This validation ensures that when a member is linked to a user, their email
|
||||
does not conflict with another user's email. It only runs when necessary
|
||||
to avoid blocking valid operations (see `@moduledoc` for trigger conditions).
|
||||
|
||||
## Parameters
|
||||
- `changeset` - The Ash changeset being validated
|
||||
- `_opts` - Options passed to the validation (unused)
|
||||
- `_context` - Ash context map (unused)
|
||||
|
||||
## Returns
|
||||
- `:ok` if validation passes or should be skipped
|
||||
- `{:error, field: :email, message: ..., value: ...}` if validation fails
|
||||
"""
|
||||
@impl true
|
||||
def validate(changeset, _opts, _context) do
|
||||
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ defmodule Mv.Repo do
|
|||
@impl true
|
||||
def installed_extensions do
|
||||
# Add extensions here, and the migration generator will install them.
|
||||
["ash-functions", "citext"]
|
||||
["ash-functions", "citext", "pg_trgm"]
|
||||
end
|
||||
|
||||
# Don't open unnecessary transactions
|
||||
|
|
|
|||
|
|
@ -1,4 +1,23 @@
|
|||
defmodule Mv.Secrets do
|
||||
@moduledoc """
|
||||
Secret provider for AshAuthentication.
|
||||
|
||||
## Purpose
|
||||
Provides runtime configuration secrets for Ash Authentication strategies,
|
||||
particularly for OIDC (Rauthy) authentication.
|
||||
|
||||
## Configuration Source
|
||||
Secrets are read from the `:rauthy` key in the application configuration,
|
||||
which is typically set in `config/runtime.exs` from environment variables:
|
||||
- `OIDC_CLIENT_ID`
|
||||
- `OIDC_CLIENT_SECRET`
|
||||
- `OIDC_BASE_URL`
|
||||
- `OIDC_REDIRECT_URI`
|
||||
|
||||
## Usage
|
||||
This module is automatically called by AshAuthentication when resolving
|
||||
secrets for the User resource's OIDC strategy.
|
||||
"""
|
||||
use AshAuthentication.Secret
|
||||
|
||||
def secret_for(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,16 @@
|
|||
defmodule MvWeb.AuthOverrides do
|
||||
@moduledoc """
|
||||
UI customizations for AshAuthentication Phoenix components.
|
||||
|
||||
## Overrides
|
||||
- `SignIn` - Restricts form width to prevent full-width display
|
||||
- `Banner` - Replaces default logo with "Mitgliederverwaltung" text
|
||||
- `HorizontalRule` - Translates "or" text to German
|
||||
|
||||
## Documentation
|
||||
For complete reference on available overrides, see:
|
||||
https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
|
||||
"""
|
||||
use AshAuthentication.Phoenix.Overrides
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,21 @@
|
|||
require Logger
|
||||
|
||||
defmodule MvWeb.AuthController do
|
||||
@moduledoc """
|
||||
Handles authentication callbacks for password and OIDC authentication.
|
||||
|
||||
This controller manages:
|
||||
- Successful authentication (password, OIDC, password reset, email confirmation)
|
||||
- Authentication failures with appropriate error handling
|
||||
- OIDC account linking flow when email collision occurs
|
||||
- Sign out functionality
|
||||
"""
|
||||
|
||||
use MvWeb, :controller
|
||||
use AshAuthentication.Phoenix.Controller
|
||||
|
||||
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||
|
||||
def success(conn, activity, user, _token) do
|
||||
return_to = get_session(conn, :return_to) || ~p"/"
|
||||
|
||||
|
|
@ -23,26 +35,144 @@ defmodule MvWeb.AuthController do
|
|||
|> redirect(to: return_to)
|
||||
end
|
||||
|
||||
def failure(conn, activity, reason) do
|
||||
Logger.error(%{conn: conn, reason: reason})
|
||||
@doc """
|
||||
Handles authentication failures and routes to appropriate error handling.
|
||||
|
||||
message =
|
||||
case {activity, reason} do
|
||||
{_,
|
||||
%AshAuthentication.Errors.AuthenticationFailed{
|
||||
caused_by: %Ash.Error.Forbidden{
|
||||
errors: [%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}]
|
||||
}
|
||||
}} ->
|
||||
gettext("""
|
||||
You have already signed in another way, but have not confirmed your account.
|
||||
You can confirm your account using the link we sent to you, or by resetting your password.
|
||||
""")
|
||||
Manages:
|
||||
- OIDC email collisions (triggers password verification flow)
|
||||
- Generic OIDC authentication failures
|
||||
- Unconfirmed account errors
|
||||
- Generic authentication failures
|
||||
"""
|
||||
def failure(conn, activity, reason) do
|
||||
Logger.warning(
|
||||
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
case {activity, reason} do
|
||||
{{:rauthy, _action}, reason} ->
|
||||
handle_rauthy_failure(conn, reason)
|
||||
|
||||
{_, %AshAuthentication.Errors.AuthenticationFailed{caused_by: caused_by}} ->
|
||||
handle_authentication_failed(conn, caused_by)
|
||||
|
||||
_ ->
|
||||
redirect_with_error(conn, gettext("Incorrect email or password"))
|
||||
end
|
||||
end
|
||||
|
||||
# Handle all Rauthy (OIDC) authentication failures
|
||||
defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do
|
||||
handle_oidc_email_collision(conn, errors)
|
||||
end
|
||||
|
||||
defp handle_rauthy_failure(conn, %AshAuthentication.Errors.AuthenticationFailed{
|
||||
caused_by: caused_by
|
||||
}) do
|
||||
case caused_by do
|
||||
%Ash.Error.Invalid{errors: errors} ->
|
||||
handle_oidc_email_collision(conn, errors)
|
||||
|
||||
_ ->
|
||||
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
|
||||
end
|
||||
end
|
||||
|
||||
# Handle generic AuthenticationFailed errors
|
||||
defp handle_authentication_failed(conn, %Ash.Error.Forbidden{errors: errors}) do
|
||||
if Enum.any?(errors, &match?(%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}, &1)) do
|
||||
message =
|
||||
gettext("""
|
||||
You have already signed in another way, but have not confirmed your account.
|
||||
You can confirm your account using the link we sent to you, or by resetting your password.
|
||||
""")
|
||||
|
||||
redirect_with_error(conn, message)
|
||||
else
|
||||
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_authentication_failed(conn, _other) do
|
||||
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
|
||||
end
|
||||
|
||||
# Handle OIDC email collision - user needs to verify password to link accounts
|
||||
defp handle_oidc_email_collision(conn, errors) do
|
||||
case find_password_verification_error(errors) do
|
||||
%PasswordVerificationRequired{user_id: user_id, oidc_user_info: oidc_user_info} ->
|
||||
redirect_to_account_linking(conn, user_id, oidc_user_info)
|
||||
|
||||
nil ->
|
||||
# Check if it's a "different OIDC account" error or email uniqueness error
|
||||
error_message = extract_meaningful_error_message(errors)
|
||||
redirect_with_error(conn, error_message)
|
||||
end
|
||||
end
|
||||
|
||||
# Extract meaningful error message from Ash errors
|
||||
defp extract_meaningful_error_message(errors) do
|
||||
# Look for specific error messages in InvalidAttribute errors
|
||||
meaningful_error =
|
||||
Enum.find_value(errors, fn
|
||||
%Ash.Error.Changes.InvalidAttribute{message: message, field: :email}
|
||||
when is_binary(message) ->
|
||||
cond do
|
||||
# Email update conflict during OIDC login
|
||||
String.contains?(message, "Cannot update email to") and
|
||||
String.contains?(message, "already registered to another account") ->
|
||||
gettext(
|
||||
"Cannot update email: This email is already registered to another account. Please change your email in the identity provider."
|
||||
)
|
||||
|
||||
# Different OIDC account error
|
||||
String.contains?(message, "already linked to a different OIDC account") ->
|
||||
gettext(
|
||||
"This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
|
||||
)
|
||||
|
||||
true ->
|
||||
nil
|
||||
end
|
||||
|
||||
%Ash.Error.Changes.InvalidAttribute{message: message}
|
||||
when is_binary(message) ->
|
||||
# Return any other meaningful message
|
||||
if String.length(message) > 20 and
|
||||
not String.contains?(message, "has already been taken") do
|
||||
message
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
_ ->
|
||||
gettext("Incorrect email or password")
|
||||
end
|
||||
nil
|
||||
end)
|
||||
|
||||
meaningful_error || gettext("Unable to sign in. Please try again.")
|
||||
end
|
||||
|
||||
# Find PasswordVerificationRequired error in error list
|
||||
defp find_password_verification_error(errors) do
|
||||
Enum.find(errors, &match?(%PasswordVerificationRequired{}, &1))
|
||||
end
|
||||
|
||||
# Redirect to account linking page with OIDC info stored in session
|
||||
defp redirect_to_account_linking(conn, user_id, oidc_user_info) do
|
||||
conn
|
||||
|> put_session(:oidc_linking_user_id, user_id)
|
||||
|> put_session(:oidc_linking_user_info, oidc_user_info)
|
||||
|> put_flash(
|
||||
:info,
|
||||
gettext(
|
||||
"An account with this email already exists. Please verify your password to link your OIDC account."
|
||||
)
|
||||
)
|
||||
|> redirect(to: ~p"/auth/link-oidc-account")
|
||||
end
|
||||
|
||||
# Generic error redirect helper
|
||||
defp redirect_with_error(conn, message) do
|
||||
conn
|
||||
|> put_flash(:error, message)
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
|
|
|
|||
296
lib/mv_web/live/auth/link_oidc_account_live.ex
Normal file
296
lib/mv_web/live/auth/link_oidc_account_live.ex
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
defmodule MvWeb.LinkOidcAccountLive do
|
||||
@moduledoc """
|
||||
LiveView for linking an OIDC account to an existing password account.
|
||||
|
||||
This page is shown when a user tries to log in via OIDC using an email
|
||||
that already exists with a password-only account. The user must verify
|
||||
their password before the OIDC account can be linked.
|
||||
|
||||
## Flow
|
||||
1. User attempts OIDC login with email that has existing password account
|
||||
2. System raises `PasswordVerificationRequired` error
|
||||
3. AuthController redirects here with user_id and oidc_user_info in session
|
||||
4. User enters password to verify identity
|
||||
5. On success, oidc_id is linked to user account
|
||||
6. User is redirected to complete OIDC login
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
with user_id when not is_nil(user_id) <- Map.get(session, "oidc_linking_user_id"),
|
||||
oidc_user_info when not is_nil(oidc_user_info) <-
|
||||
Map.get(session, "oidc_linking_user_info"),
|
||||
{:ok, user} <- Ash.get(Mv.Accounts.User, user_id) do
|
||||
# Check if user is passwordless
|
||||
if passwordless?(user) do
|
||||
# Auto-link passwordless user immediately
|
||||
{:ok, auto_link_passwordless_user(socket, user, oidc_user_info)}
|
||||
else
|
||||
# Show password form for password-protected user
|
||||
{:ok, initialize_socket(socket, user, oidc_user_info)}
|
||||
end
|
||||
else
|
||||
nil ->
|
||||
{:ok, redirect_with_error(socket, dgettext("auth", "Invalid session. Please try again."))}
|
||||
|
||||
{:error, _} ->
|
||||
{:ok, redirect_with_error(socket, dgettext("auth", "Session expired. Please try again."))}
|
||||
end
|
||||
end
|
||||
|
||||
defp passwordless?(user) do
|
||||
is_nil(user.hashed_password)
|
||||
end
|
||||
|
||||
defp reload_user!(user_id) do
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(id == ^user_id)
|
||||
|> Ash.read_one!()
|
||||
end
|
||||
|
||||
defp reset_password_form(socket) do
|
||||
assign(socket, :form, to_form(%{"password" => ""}))
|
||||
end
|
||||
|
||||
defp auto_link_passwordless_user(socket, user, oidc_user_info) do
|
||||
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
|
||||
|
||||
case user.id
|
||||
|> reload_user!()
|
||||
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
||||
oidc_id: oidc_id,
|
||||
oidc_user_info: oidc_user_info
|
||||
})
|
||||
|> Ash.update() do
|
||||
{:ok, updated_user} ->
|
||||
Logger.info(
|
||||
"Passwordless account auto-linked to OIDC: user_id=#{updated_user.id}, oidc_id=#{oidc_id}"
|
||||
)
|
||||
|
||||
socket
|
||||
|> put_flash(
|
||||
:info,
|
||||
dgettext("auth", "Account activated! Redirecting to complete sign-in...")
|
||||
)
|
||||
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/rauthy")
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning(
|
||||
"Failed to auto-link passwordless account: user_id=#{user.id}, error=#{inspect(error)}"
|
||||
)
|
||||
|
||||
error_message = extract_user_friendly_error(error)
|
||||
|
||||
socket
|
||||
|> put_flash(:error, error_message)
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_user_friendly_error(%Ash.Error.Invalid{errors: errors}) do
|
||||
# Check for specific error types
|
||||
Enum.find_value(errors, fn
|
||||
%Ash.Error.Changes.InvalidAttribute{field: :oidc_id, message: message} ->
|
||||
if String.contains?(message, "already been taken") do
|
||||
dgettext(
|
||||
"auth",
|
||||
"This OIDC account is already linked to another user. Please contact support."
|
||||
)
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
%Ash.Error.Changes.InvalidAttribute{field: :email, message: message} ->
|
||||
if String.contains?(message, "already been taken") do
|
||||
dgettext(
|
||||
"auth",
|
||||
"The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
|
||||
)
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end) ||
|
||||
dgettext("auth", "Failed to link account. Please try again or contact support.")
|
||||
end
|
||||
|
||||
defp extract_user_friendly_error(_error) do
|
||||
dgettext("auth", "Failed to link account. Please try again or contact support.")
|
||||
end
|
||||
|
||||
defp initialize_socket(socket, user, oidc_user_info) do
|
||||
socket
|
||||
|> assign(:user, user)
|
||||
|> assign(:oidc_user_info, oidc_user_info)
|
||||
|> assign(:password, "")
|
||||
|> assign(:error, nil)
|
||||
|> reset_password_form()
|
||||
end
|
||||
|
||||
defp redirect_with_error(socket, message) do
|
||||
socket
|
||||
|> put_flash(:error, message)
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"password" => password}, socket) do
|
||||
{:noreply, assign(socket, :password, password)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("submit", %{"password" => password}, socket) do
|
||||
user = socket.assigns.user
|
||||
oidc_user_info = socket.assigns.oidc_user_info
|
||||
|
||||
# Verify the password using AshAuthentication
|
||||
case verify_password(user.email, password) do
|
||||
{:ok, verified_user} ->
|
||||
# Password correct - link the OIDC account
|
||||
link_oidc_account(socket, verified_user, oidc_user_info)
|
||||
|
||||
{:error, _reason} ->
|
||||
# Password incorrect - log security event
|
||||
Logger.warning("Failed password verification for OIDC linking: user_email=#{user.email}")
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:error, dgettext("auth", "Incorrect password. Please try again."))
|
||||
|> reset_password_form()}
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_password(email, password) do
|
||||
# Use AshAuthentication password strategy to verify
|
||||
strategies = AshAuthentication.Info.authentication_strategies(Mv.Accounts.User)
|
||||
password_strategy = Enum.find(strategies, fn s -> s.name == :password end)
|
||||
|
||||
if password_strategy do
|
||||
AshAuthentication.Strategy.Password.Actions.sign_in(
|
||||
password_strategy,
|
||||
%{
|
||||
"email" => email,
|
||||
"password" => password
|
||||
},
|
||||
[]
|
||||
)
|
||||
else
|
||||
{:error, "Password authentication not configured"}
|
||||
end
|
||||
end
|
||||
|
||||
defp link_oidc_account(socket, user, oidc_user_info) do
|
||||
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
|
||||
|
||||
# Update the user with the OIDC ID
|
||||
case user.id
|
||||
|> reload_user!()
|
||||
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
||||
oidc_id: oidc_id,
|
||||
oidc_user_info: oidc_user_info
|
||||
})
|
||||
|> Ash.update() do
|
||||
{:ok, updated_user} ->
|
||||
# After successful linking, redirect to OIDC login
|
||||
# Since the user now has an oidc_id, the next OIDC login will succeed
|
||||
Logger.info(
|
||||
"OIDC account successfully linked after password verification: user_id=#{updated_user.id}, oidc_id=#{oidc_id}"
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(
|
||||
:info,
|
||||
dgettext(
|
||||
"auth",
|
||||
"Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
||||
)
|
||||
)
|
||||
|> Phoenix.LiveView.redirect(to: ~p"/auth/user/rauthy")}
|
||||
|
||||
{:error, error} ->
|
||||
Logger.warning(
|
||||
"Failed to link OIDC account after password verification: user_id=#{user.id}, error=#{inspect(error)}"
|
||||
)
|
||||
|
||||
error_message = extract_user_friendly_error(error)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:error, error_message)
|
||||
|> reset_password_form()}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="mx-auto max-w-sm mt-16">
|
||||
<%!-- Language Selector --%>
|
||||
<nav aria-label={dgettext("auth", "Language selection")} class="flex justify-center mb-4">
|
||||
<form method="post" action="/set_locale" class="text-sm">
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<select
|
||||
name="locale"
|
||||
onchange="this.form.submit()"
|
||||
class="select select-sm select-bordered"
|
||||
aria-label={dgettext("auth", "Select language")}
|
||||
>
|
||||
<option value="de" selected={Gettext.get_locale() == "de"}>🇩🇪 Deutsch</option>
|
||||
<option value="en" selected={Gettext.get_locale() == "en"}>🇬🇧 English</option>
|
||||
</select>
|
||||
</form>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<.header class="text-center">
|
||||
{dgettext("auth", "Link OIDC Account")}
|
||||
<:subtitle>
|
||||
{dgettext(
|
||||
"auth",
|
||||
"An account with email %{email} already exists. Please enter your password to link your OIDC account.",
|
||||
email: @user.email
|
||||
)}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form for={@form} id="link-oidc-form" phx-submit="submit" phx-change="validate" class="mt-8">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<.input
|
||||
field={@form[:password]}
|
||||
type="password"
|
||||
label={dgettext("auth", "Password")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%= if @error do %>
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<p class="text-sm text-red-800">{@error}</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<.button phx-disable-with={dgettext("auth", "Linking...")} class="w-full">
|
||||
{dgettext("auth", "Link Account")}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
<div class="mt-4 text-center text-sm">
|
||||
<.link navigate={~p"/sign-in"} class="text-brand hover:underline">
|
||||
{dgettext("auth", "Cancel")}
|
||||
</.link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -1,4 +1,33 @@
|
|||
defmodule MvWeb.MemberLive.Form do
|
||||
@moduledoc """
|
||||
LiveView form for creating and editing members.
|
||||
|
||||
## Features
|
||||
- Create new members with personal information
|
||||
- Edit existing member details
|
||||
- Manage custom properties (dynamic fields)
|
||||
- Real-time validation with visual feedback
|
||||
- Link/unlink user accounts
|
||||
|
||||
## Form Fields
|
||||
**Required:**
|
||||
- first_name, last_name, email
|
||||
|
||||
**Optional:**
|
||||
- birth_date, phone_number, address fields (city, street, house_number, postal_code)
|
||||
- join_date, exit_date
|
||||
- paid status
|
||||
- notes
|
||||
|
||||
## Custom Properties
|
||||
Members can have dynamic custom properties defined by PropertyTypes.
|
||||
The form dynamically renders inputs based on available PropertyTypes.
|
||||
|
||||
## Events
|
||||
- `validate` - Real-time form validation
|
||||
- `save` - Submit form (create or update member)
|
||||
- Property management events for adding/removing custom fields
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
|
|
@ -1,8 +1,37 @@
|
|||
defmodule MvWeb.MemberLive.Index do
|
||||
use MvWeb, :live_view
|
||||
import Ash.Expr
|
||||
import Ash.Query
|
||||
@moduledoc """
|
||||
LiveView for displaying and managing the member list.
|
||||
|
||||
## Features
|
||||
- Full-text search across member profiles using PostgreSQL tsvector
|
||||
- Sortable columns (name, email, address fields)
|
||||
- Bulk selection for future batch operations
|
||||
- Real-time updates via LiveView
|
||||
- Bookmarkable URLs with query parameters
|
||||
|
||||
## URL Parameters
|
||||
- `query` - Search query string for full-text search
|
||||
- `sort_field` - Field to sort by (e.g., :first_name, :email, :join_date)
|
||||
- `sort_order` - Sort direction (:asc or :desc)
|
||||
|
||||
## Events
|
||||
- `delete` - Remove a member from the database
|
||||
- `select_member` - Toggle individual member selection
|
||||
- `select_all` - Toggle selection of all visible members
|
||||
|
||||
## Implementation Notes
|
||||
- Search uses PostgreSQL full-text search (plainto_tsquery)
|
||||
- Sort state is synced with URL for bookmarkability
|
||||
- Components communicate via `handle_info` for decoupling
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@doc """
|
||||
Initializes the LiveView state.
|
||||
|
||||
Sets up initial assigns for page title, search query, sort configuration,
|
||||
and member selection. Actual data loading happens in `handle_params/3`.
|
||||
"""
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
socket =
|
||||
|
|
@ -21,7 +50,14 @@ defmodule MvWeb.MemberLive.Index do
|
|||
# Handle Events
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
# Delete a member
|
||||
@doc """
|
||||
Handles member-related UI events.
|
||||
|
||||
## Supported events:
|
||||
- `"delete"` - Removes a member from the database
|
||||
- `"select_member"` - Toggles individual member selection
|
||||
- `"select_all"` - Toggles selection of all visible members
|
||||
"""
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
member = Ash.get!(Mv.Membership.Member, id)
|
||||
|
|
@ -31,7 +67,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
{:noreply, assign(socket, :members, updated_members)}
|
||||
end
|
||||
|
||||
# Selects one member in the list of members
|
||||
@impl true
|
||||
def handle_event("select_member", %{"id" => id}, socket) do
|
||||
selected =
|
||||
|
|
@ -44,7 +79,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
{:noreply, assign(socket, :selected_members, selected)}
|
||||
end
|
||||
|
||||
# Selects all members in the list of members
|
||||
@impl true
|
||||
def handle_event("select_all", _params, socket) do
|
||||
members = socket.assigns.members
|
||||
|
|
@ -65,57 +99,23 @@ defmodule MvWeb.MemberLive.Index do
|
|||
# Handle Infos from Child Components
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
# Sorts the list of members according to a field, when you click on the column header
|
||||
@doc """
|
||||
Handles messages from child components.
|
||||
|
||||
## Supported messages:
|
||||
- `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
|
||||
- `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
|
||||
"""
|
||||
@impl true
|
||||
def handle_info({:sort, field_str}, socket) do
|
||||
field = String.to_existing_atom(field_str)
|
||||
old_field = socket.assigns.sort_field
|
||||
{new_field, new_order} = determine_new_sort(field, socket)
|
||||
|
||||
{new_order, new_field} =
|
||||
if socket.assigns.sort_field == field do
|
||||
{toggle_order(socket.assigns.sort_order), field}
|
||||
else
|
||||
{:asc, field}
|
||||
end
|
||||
|
||||
active_id = :"sort_#{new_field}"
|
||||
old_id = :"sort_#{old_field}"
|
||||
|
||||
# Update the new SortHeader
|
||||
send_update(MvWeb.Components.SortHeaderComponent,
|
||||
id: active_id,
|
||||
sort_field: new_field,
|
||||
sort_order: new_order
|
||||
)
|
||||
|
||||
# Reset the current SortHeader
|
||||
send_update(MvWeb.Components.SortHeaderComponent,
|
||||
id: old_id,
|
||||
sort_field: new_field,
|
||||
sort_order: new_order
|
||||
)
|
||||
|
||||
existing_search_query = socket.assigns.query
|
||||
|
||||
# Build the URL with queries
|
||||
query_params = %{
|
||||
"query" => existing_search_query,
|
||||
"sort_field" => Atom.to_string(new_field),
|
||||
"sort_order" => Atom.to_string(new_order)
|
||||
}
|
||||
|
||||
# Set the new path with params
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
||||
# Push the new URL
|
||||
{:noreply,
|
||||
push_patch(socket,
|
||||
to: new_path,
|
||||
replace: true
|
||||
)}
|
||||
socket
|
||||
|> update_sort_components(socket.assigns.sort_field, new_field, new_order)
|
||||
|> push_sort_url(new_field, new_order)
|
||||
end
|
||||
|
||||
# Function to handle search
|
||||
@impl true
|
||||
def handle_info({:search_changed, q}, socket) do
|
||||
socket = load_members(socket, q)
|
||||
|
|
@ -144,6 +144,13 @@ defmodule MvWeb.MemberLive.Index do
|
|||
# -----------------------------------------------------------------
|
||||
# Handle Params from the URL
|
||||
# -----------------------------------------------------------------
|
||||
@doc """
|
||||
Handles URL parameter changes.
|
||||
|
||||
Parses query parameters for search query, sort field, and sort order,
|
||||
then loads members accordingly. This enables bookmarkable URLs and
|
||||
browser back/forward navigation.
|
||||
"""
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
socket =
|
||||
|
|
@ -158,6 +165,55 @@ defmodule MvWeb.MemberLive.Index do
|
|||
# -------------------------------------------------------------
|
||||
# FUNCTIONS
|
||||
# -------------------------------------------------------------
|
||||
|
||||
# Determines new sort field and order based on current state
|
||||
defp determine_new_sort(field, socket) do
|
||||
if socket.assigns.sort_field == field do
|
||||
{field, toggle_order(socket.assigns.sort_order)}
|
||||
else
|
||||
{field, :asc}
|
||||
end
|
||||
end
|
||||
|
||||
# Updates both the active and old SortHeader components
|
||||
defp update_sort_components(socket, old_field, new_field, new_order) do
|
||||
active_id = :"sort_#{new_field}"
|
||||
old_id = :"sort_#{old_field}"
|
||||
|
||||
# Update the new SortHeader
|
||||
send_update(MvWeb.Components.SortHeaderComponent,
|
||||
id: active_id,
|
||||
sort_field: new_field,
|
||||
sort_order: new_order
|
||||
)
|
||||
|
||||
# Reset the current SortHeader
|
||||
send_update(MvWeb.Components.SortHeaderComponent,
|
||||
id: old_id,
|
||||
sort_field: new_field,
|
||||
sort_order: new_order
|
||||
)
|
||||
|
||||
socket
|
||||
end
|
||||
|
||||
# Builds sort URL and pushes navigation patch
|
||||
defp push_sort_url(socket, field, order) do
|
||||
query_params = %{
|
||||
"query" => socket.assigns.query,
|
||||
"sort_field" => Atom.to_string(field),
|
||||
"sort_order" => Atom.to_string(order)
|
||||
}
|
||||
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
||||
{:noreply,
|
||||
push_patch(socket,
|
||||
to: new_path,
|
||||
replace: true
|
||||
)}
|
||||
end
|
||||
|
||||
# Load members eg based on a query for sorting
|
||||
defp load_members(socket, search_query) do
|
||||
query =
|
||||
|
|
@ -194,7 +250,9 @@ defmodule MvWeb.MemberLive.Index do
|
|||
defp apply_search_filter(query, search_query) do
|
||||
if search_query && String.trim(search_query) != "" do
|
||||
query
|
||||
|> filter(expr(fragment("search_vector @@ plainto_tsquery('simple', ?)", ^search_query)))
|
||||
|> Mv.Membership.Member.fuzzy_search(%{
|
||||
query: search_query
|
||||
})
|
||||
else
|
||||
query
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,26 @@
|
|||
defmodule MvWeb.MemberLive.Show do
|
||||
@moduledoc """
|
||||
LiveView for displaying a single member's details.
|
||||
|
||||
## Features
|
||||
- Display all member information (personal, contact, address)
|
||||
- Show linked user account (if exists)
|
||||
- Display custom properties
|
||||
- Navigate to edit form
|
||||
- Return to member list
|
||||
|
||||
## Displayed Information
|
||||
- Basic: name, email, dates (birth, join, exit)
|
||||
- Contact: phone number
|
||||
- Address: street, house number, postal code, city
|
||||
- Status: paid flag
|
||||
- Relationships: linked user account
|
||||
- Custom: dynamic properties from PropertyTypes
|
||||
|
||||
## Navigation
|
||||
- Back to member list
|
||||
- Edit member (with return_to parameter for back navigation)
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
import Ash.Query
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,35 @@
|
|||
defmodule MvWeb.PropertyLive.Form do
|
||||
@moduledoc """
|
||||
LiveView form for creating and editing properties.
|
||||
|
||||
## Features
|
||||
- Create new properties with member and type selection
|
||||
- Edit existing property values
|
||||
- Value input adapts to property type (string, integer, boolean, date, email)
|
||||
- Real-time validation
|
||||
|
||||
## Form Fields
|
||||
**Required:**
|
||||
- member - Select which member owns this property
|
||||
- property_type - Select the type (defines value type)
|
||||
- value - The actual value (input type depends on property type)
|
||||
|
||||
## Value Types
|
||||
The form dynamically renders appropriate inputs based on property type:
|
||||
- String: text input
|
||||
- Integer: number input
|
||||
- Boolean: checkbox
|
||||
- Date: date picker
|
||||
- Email: email input with validation
|
||||
|
||||
## Events
|
||||
- `validate` - Real-time form validation
|
||||
- `save` - Submit form (create or update property)
|
||||
|
||||
## Note
|
||||
Properties are typically managed through the member edit form,
|
||||
not through this standalone form.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
|
|
@ -1,4 +1,26 @@
|
|||
defmodule MvWeb.PropertyLive.Index do
|
||||
@moduledoc """
|
||||
LiveView for displaying and managing properties.
|
||||
|
||||
## Features
|
||||
- List all properties with their values and types
|
||||
- Show which member each property belongs to
|
||||
- Display property type information
|
||||
- Navigate to property details and edit forms
|
||||
- Delete properties
|
||||
|
||||
## Relationships
|
||||
Each property is linked to:
|
||||
- A member (the property owner)
|
||||
- A property type (defining value type and behavior)
|
||||
|
||||
## Events
|
||||
- `delete` - Remove a property from the database
|
||||
|
||||
## Note
|
||||
Properties are typically managed through the member edit form.
|
||||
This view provides a global overview of all properties.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
|
|
@ -1,4 +1,24 @@
|
|||
defmodule MvWeb.PropertyLive.Show do
|
||||
@moduledoc """
|
||||
LiveView for displaying a single property's details.
|
||||
|
||||
## Features
|
||||
- Display property value and type
|
||||
- Show linked member
|
||||
- Show property type definition
|
||||
- Navigate to edit form
|
||||
- Return to property list
|
||||
|
||||
## Displayed Information
|
||||
- Property value (formatted based on type)
|
||||
- Property type name and description
|
||||
- Member information (who owns this property)
|
||||
- Property metadata (ID, timestamps if added)
|
||||
|
||||
## Navigation
|
||||
- Back to property list
|
||||
- Edit property
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
|
|
@ -1,4 +1,38 @@
|
|||
defmodule MvWeb.PropertyTypeLive.Form do
|
||||
@moduledoc """
|
||||
LiveView form for creating and editing property types (admin).
|
||||
|
||||
## Features
|
||||
- Create new property type definitions
|
||||
- Edit existing property types
|
||||
- Select value type from supported types
|
||||
- Set immutable and required flags
|
||||
- Real-time validation
|
||||
|
||||
## Form Fields
|
||||
**Required:**
|
||||
- name - Unique identifier (e.g., "phone_mobile", "emergency_contact")
|
||||
- value_type - Data type (:string, :integer, :boolean, :date, :email)
|
||||
|
||||
**Optional:**
|
||||
- description - Human-readable explanation
|
||||
- immutable - If true, values cannot be changed after creation (default: false)
|
||||
- required - If true, all members must have this property (default: false)
|
||||
|
||||
## Value Type Selection
|
||||
- `:string` - Text data (unlimited length)
|
||||
- `:integer` - Numeric data
|
||||
- `:boolean` - True/false flags
|
||||
- `:date` - Date values
|
||||
- `:email` - Validated email addresses
|
||||
|
||||
## Events
|
||||
- `validate` - Real-time form validation
|
||||
- `save` - Submit form (create or update property type)
|
||||
|
||||
## Security
|
||||
Property type management is restricted to admin users.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
|
|
@ -1,4 +1,28 @@
|
|||
defmodule MvWeb.PropertyTypeLive.Index do
|
||||
@moduledoc """
|
||||
LiveView for managing property type definitions (admin).
|
||||
|
||||
## Features
|
||||
- List all property types
|
||||
- Display type information (name, value type, description)
|
||||
- Show immutable and required flags
|
||||
- Create new property types
|
||||
- Edit existing property types
|
||||
- Delete property types (if no properties use them)
|
||||
|
||||
## Displayed Information
|
||||
- Name: Unique identifier for the property type
|
||||
- Value type: Data type constraint (string, integer, boolean, date, email)
|
||||
- Description: Human-readable explanation
|
||||
- Immutable: Whether property values can be changed after creation
|
||||
- Required: Whether all members must have this property (future feature)
|
||||
|
||||
## Events
|
||||
- `delete` - Remove a property type (only if no properties exist)
|
||||
|
||||
## Security
|
||||
Property type management is restricted to admin users.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
|
|
@ -1,4 +1,27 @@
|
|||
defmodule MvWeb.PropertyTypeLive.Show do
|
||||
@moduledoc """
|
||||
LiveView for displaying a single property type's details (admin).
|
||||
|
||||
## Features
|
||||
- Display property type definition
|
||||
- Show all attributes (name, value type, description, flags)
|
||||
- Navigate to edit form
|
||||
- Return to property type list
|
||||
|
||||
## Displayed Information
|
||||
- Name: Unique identifier
|
||||
- Value type: Data type constraint
|
||||
- Description: Optional explanation
|
||||
- Immutable flag: Whether values can be changed
|
||||
- Required flag: Whether all members need this property
|
||||
|
||||
## Navigation
|
||||
- Back to property type list
|
||||
- Edit property type
|
||||
|
||||
## Security
|
||||
Property type details are restricted to admin users.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
|
|
@ -1,4 +1,36 @@
|
|||
defmodule MvWeb.UserLive.Form do
|
||||
@moduledoc """
|
||||
LiveView form for creating and editing users.
|
||||
|
||||
## Features
|
||||
- Create new users with email
|
||||
- Edit existing user details
|
||||
- Optional password setting (checkbox to toggle)
|
||||
- Link/unlink member accounts
|
||||
- Email synchronization with linked members
|
||||
|
||||
## Form Fields
|
||||
**Required:**
|
||||
- email
|
||||
|
||||
**Optional:**
|
||||
- password (for password authentication strategy)
|
||||
- linked member (select from existing members)
|
||||
|
||||
## Password Management
|
||||
- New users: Can optionally set password with confirmation
|
||||
- Existing users: Can change password (no confirmation required, admin action)
|
||||
- Checkbox toggles password section visibility
|
||||
|
||||
## Member Linking
|
||||
Users can be linked to existing member accounts. When linked, emails are
|
||||
synchronized bidirectionally with User.email as the source of truth.
|
||||
|
||||
## Events
|
||||
- `validate` - Real-time form validation
|
||||
- `save` - Submit form (create or update user)
|
||||
- `toggle_password_section` - Show/hide password fields
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
|
|
@ -1,4 +1,25 @@
|
|||
defmodule MvWeb.UserLive.Index do
|
||||
@moduledoc """
|
||||
LiveView for displaying and managing the user list.
|
||||
|
||||
## Features
|
||||
- List all users with email and linked member
|
||||
- Sort users by email (default)
|
||||
- Delete users
|
||||
- Navigate to user details and edit forms
|
||||
- Bulk selection for future batch operations
|
||||
|
||||
## Relationships
|
||||
Displays linked member information when a user is connected to a member account.
|
||||
|
||||
## Events
|
||||
- `delete` - Remove a user from the database
|
||||
- `select_user` - Toggle individual user selection
|
||||
- `select_all` - Toggle selection of all visible users
|
||||
|
||||
## Security
|
||||
User deletion requires admin permissions (enforced by Ash policies).
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
import MvWeb.TableComponents
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,29 @@
|
|||
defmodule MvWeb.UserLive.Show do
|
||||
@moduledoc """
|
||||
LiveView for displaying a single user's details.
|
||||
|
||||
## Features
|
||||
- Display user information (email, OIDC ID)
|
||||
- Show authentication methods (password, OIDC)
|
||||
- Display linked member account (if exists)
|
||||
- Navigate to edit form
|
||||
- Return to user list
|
||||
|
||||
## Displayed Information
|
||||
- Email address
|
||||
- OIDC ID (if authenticated via OIDC)
|
||||
- Password authentication status
|
||||
- Linked member (name and email)
|
||||
|
||||
## Authentication Status
|
||||
Shows which authentication methods are enabled for the user:
|
||||
- Password authentication (has hashed_password)
|
||||
- OIDC authentication (has oidc_id)
|
||||
|
||||
## Navigation
|
||||
- Back to user list
|
||||
- Edit user (with return_to parameter for back navigation)
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
|
|
@ -1,4 +1,16 @@
|
|||
defmodule MvWeb.LiveHelpers do
|
||||
@moduledoc """
|
||||
Shared LiveView lifecycle hooks and helper functions.
|
||||
|
||||
## on_mount Hooks
|
||||
- `:default` - Sets the user's locale from session (defaults to "de")
|
||||
|
||||
## Usage
|
||||
Add to LiveView modules via:
|
||||
```elixir
|
||||
on_mount {MvWeb.LiveHelpers, :default}
|
||||
```
|
||||
"""
|
||||
def on_mount(:default, _params, session, socket) do
|
||||
locale = session["locale"] || "de"
|
||||
Gettext.put_locale(locale)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@ defmodule MvWeb.LocaleController do
|
|||
def set_locale(conn, %{"locale" => locale}) do
|
||||
conn
|
||||
|> put_session(:locale, locale)
|
||||
# Store locale in a cookie that persists beyond the session
|
||||
|> put_resp_cookie("locale", locale,
|
||||
max_age: 365 * 24 * 60 * 60,
|
||||
same_site: "Lax",
|
||||
http_only: true,
|
||||
secure: Application.get_env(:mv, :use_secure_cookies, false)
|
||||
)
|
||||
|> redirect(to: get_referer(conn) || "/")
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ defmodule MvWeb.Router do
|
|||
post "/set_locale", LocaleController, :set_locale
|
||||
end
|
||||
|
||||
# OIDC account linking - user needs to verify password (MUST be before auth_routes!)
|
||||
live "/auth/link-oidc-account", LinkOidcAccountLive
|
||||
|
||||
# ASHAUTHENTICATION GENERATED AUTH ROUTES
|
||||
auth_routes AuthController, Mv.Accounts.User, path: "/auth"
|
||||
sign_out_route AuthController
|
||||
|
|
@ -141,6 +144,7 @@ defmodule MvWeb.Router do
|
|||
defp set_locale(conn, _opts) do
|
||||
locale =
|
||||
get_session(conn, :locale) ||
|
||||
get_locale_from_cookie(conn) ||
|
||||
extract_locale_from_headers(conn.req_headers)
|
||||
|
||||
Gettext.put_locale(MvWeb.Gettext, locale)
|
||||
|
|
@ -150,6 +154,13 @@ defmodule MvWeb.Router do
|
|||
|> assign(:locale, locale)
|
||||
end
|
||||
|
||||
defp get_locale_from_cookie(conn) do
|
||||
case conn.req_cookies do
|
||||
%{"locale" => locale} when locale in ["en", "de"] -> locale
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Get locale from user
|
||||
defp extract_locale_from_headers(headers) do
|
||||
headers
|
||||
|
|
|
|||
2
mix.exs
2
mix.exs
|
|
@ -22,7 +22,7 @@ defmodule Mv.MixProject do
|
|||
def application do
|
||||
[
|
||||
mod: {Mv.Application, []},
|
||||
extra_applications: [:logger, :runtime_tools]
|
||||
extra_applications: [:logger, :runtime_tools, :gettext]
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ msgstr ""
|
|||
msgid "Need an account?"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:268
|
||||
#, elixir-autogen
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -62,3 +64,79 @@ msgstr ""
|
|||
|
||||
msgid "Your password has successfully been reset"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:254
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:289
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:163
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Incorrect password. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:37
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid session. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:281
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Link Account"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:252
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Link OIDC Account"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:280
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Linking..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:40
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Session expired. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:209
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:76
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Account activated! Redirecting to complete sign-in..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:119
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:123
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to link account. Please try again or contact support."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:108
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:98
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This OIDC account is already linked to another user. Please contact support."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:235
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Language selection"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:242
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select language"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ msgstr "Falls diese*r Benutzer*in bekannt ist, wird jetzt eine Email mit einer A
|
|||
msgid "Need an account?"
|
||||
msgstr "Konto anlegen?"
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:268
|
||||
#, elixir-autogen
|
||||
msgid "Password"
|
||||
msgstr "Passwort"
|
||||
|
||||
|
|
@ -62,5 +64,78 @@ msgstr "Anmelden..."
|
|||
msgid "Your password has successfully been reset"
|
||||
msgstr "Das Passwort wurde erfolgreich zurückgesetzt"
|
||||
|
||||
#~ msgid "Sign in with Rauthy"
|
||||
#~ msgstr "Anmelden mit der Vereinscloud"
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:254
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
|
||||
msgstr "Ein Konto mit der E-Mail %{email} existiert bereits. Bitte geben Sie Ihr Passwort ein, um Ihr OIDC-Konto zu verknüpfen."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:289
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
msgstr "Abbrechen"
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:163
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Incorrect password. Please try again."
|
||||
msgstr "Falsches Passwort. Bitte versuchen Sie es erneut."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:37
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid session. Please try again."
|
||||
msgstr "Ungültige Sitzung. Bitte versuchen Sie es erneut."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:281
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Link Account"
|
||||
msgstr "Konto verknüpfen"
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:252
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Link OIDC Account"
|
||||
msgstr "OIDC-Konto verknüpfen"
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:280
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Linking..."
|
||||
msgstr "Verknüpfen..."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:40
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Session expired. Please try again."
|
||||
msgstr "Sitzung abgelaufen. Bitte versuchen Sie es erneut."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:209
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
||||
msgstr "Ihr OIDC-Konto wurde erfolgreich verknüpft! Sie werden zur Anmeldung weitergeleitet..."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:76
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Account activated! Redirecting to complete sign-in..."
|
||||
msgstr "Konto aktiviert! Sie werden zur Anmeldung weitergeleitet..."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:119
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:123
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to link account. Please try again or contact support."
|
||||
msgstr "Verknüpfung des Kontos fehlgeschlagen. Bitte versuchen Sie es erneut oder kontaktieren Sie den Support."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:108
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
|
||||
msgstr "Die E-Mail-Adresse aus Ihrem OIDC-Provider ist bereits für ein anderes Konto registriert. Bitte ändern Sie Ihre E-Mail-Adresse im Identity-Provider oder kontaktieren Sie den Support."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:98
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This OIDC account is already linked to another user. Please contact support."
|
||||
msgstr "Dieses OIDC-Konto ist bereits mit einem anderen Benutzer verknüpft. Bitte kontaktieren Sie den Support."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:235
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Language selection"
|
||||
msgstr "Sprachauswahl"
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:242
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select language"
|
||||
msgstr "Sprache auswählen"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ msgstr ""
|
|||
msgid "Actions"
|
||||
msgstr "Aktionen"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:193
|
||||
#: lib/mv_web/live/member_live/index.html.heex:200
|
||||
#: lib/mv_web/live/user_live/index.html.heex:65
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Are you sure?"
|
||||
|
|
@ -28,19 +28,19 @@ msgid "Attempting to reconnect"
|
|||
msgstr "Verbindung wird wiederhergestellt"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:25
|
||||
#: lib/mv_web/live/member_live/index.html.heex:138
|
||||
#: lib/mv_web/live/member_live/show.ex:36
|
||||
#: lib/mv_web/live/member_live/index.html.heex:145
|
||||
#: lib/mv_web/live/member_live/show.ex:37
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "City"
|
||||
msgstr "Stadt"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:195
|
||||
#: lib/mv_web/live/member_live/index.html.heex:202
|
||||
#: lib/mv_web/live/user_live/index.html.heex:67
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete"
|
||||
msgstr "Löschen"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:187
|
||||
#: lib/mv_web/live/member_live/index.html.heex:194
|
||||
#: lib/mv_web/live/user_live/form.ex:109
|
||||
#: lib/mv_web/live/user_live/index.html.heex:59
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -54,8 +54,8 @@ msgid "Edit Member"
|
|||
msgstr "Mitglied bearbeiten"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:18
|
||||
#: lib/mv_web/live/member_live/index.html.heex:70
|
||||
#: lib/mv_web/live/member_live/show.ex:27
|
||||
#: lib/mv_web/live/member_live/index.html.heex:77
|
||||
#: lib/mv_web/live/member_live/show.ex:28
|
||||
#: lib/mv_web/live/user_live/form.ex:14
|
||||
#: lib/mv_web/live/user_live/index.html.heex:44
|
||||
#: lib/mv_web/live/user_live/show.ex:25
|
||||
|
|
@ -70,8 +70,8 @@ msgid "First Name"
|
|||
msgstr "Vorname"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:22
|
||||
#: lib/mv_web/live/member_live/index.html.heex:172
|
||||
#: lib/mv_web/live/member_live/show.ex:33
|
||||
#: lib/mv_web/live/member_live/index.html.heex:179
|
||||
#: lib/mv_web/live/member_live/show.ex:34
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join Date"
|
||||
msgstr "Beitrittsdatum"
|
||||
|
|
@ -87,7 +87,7 @@ msgstr "Nachname"
|
|||
msgid "New Member"
|
||||
msgstr "Neues Mitglied"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:184
|
||||
#: lib/mv_web/live/member_live/index.html.heex:191
|
||||
#: lib/mv_web/live/user_live/index.html.heex:56
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Show"
|
||||
|
|
@ -127,8 +127,8 @@ msgid "Exit Date"
|
|||
msgstr "Austrittsdatum"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:27
|
||||
#: lib/mv_web/live/member_live/index.html.heex:104
|
||||
#: lib/mv_web/live/member_live/show.ex:38
|
||||
#: lib/mv_web/live/member_live/index.html.heex:111
|
||||
#: lib/mv_web/live/member_live/show.ex:39
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "House Number"
|
||||
msgstr "Hausnummer"
|
||||
|
|
@ -146,15 +146,15 @@ msgid "Paid"
|
|||
msgstr "Bezahlt"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:21
|
||||
#: lib/mv_web/live/member_live/index.html.heex:155
|
||||
#: lib/mv_web/live/member_live/show.ex:32
|
||||
#: lib/mv_web/live/member_live/index.html.heex:162
|
||||
#: lib/mv_web/live/member_live/show.ex:33
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Phone Number"
|
||||
msgstr "Telefonnummer"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:28
|
||||
#: lib/mv_web/live/member_live/index.html.heex:121
|
||||
#: lib/mv_web/live/member_live/show.ex:39
|
||||
#: lib/mv_web/live/member_live/index.html.heex:128
|
||||
#: lib/mv_web/live/member_live/show.ex:40
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Postal Code"
|
||||
msgstr "Postleitzahl"
|
||||
|
|
@ -173,8 +173,8 @@ msgid "Saving..."
|
|||
msgstr "Speichern..."
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:26
|
||||
#: lib/mv_web/live/member_live/index.html.heex:87
|
||||
#: lib/mv_web/live/member_live/show.ex:37
|
||||
#: lib/mv_web/live/member_live/index.html.heex:94
|
||||
#: lib/mv_web/live/member_live/show.ex:38
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Street"
|
||||
msgstr "Straße"
|
||||
|
|
@ -223,7 +223,7 @@ msgstr "erstellt"
|
|||
msgid "update"
|
||||
msgstr "aktualisiert"
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:43
|
||||
#: lib/mv_web/controllers/auth_controller.ex:60
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Incorrect email or password"
|
||||
msgstr "Falsche E-Mail oder Passwort"
|
||||
|
|
@ -233,27 +233,27 @@ msgstr "Falsche E-Mail oder Passwort"
|
|||
msgid "Member %{action} successfully"
|
||||
msgstr "Mitglied %{action} erfolgreich"
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:14
|
||||
#: lib/mv_web/controllers/auth_controller.ex:26
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You are now signed in"
|
||||
msgstr "Sie sind jetzt angemeldet"
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:56
|
||||
#: lib/mv_web/controllers/auth_controller.ex:186
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You are now signed out"
|
||||
msgstr "Sie sind jetzt abgemeldet"
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:37
|
||||
#: lib/mv_web/controllers/auth_controller.ex:85
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You have already signed in another way, but have not confirmed your account.\nYou can confirm your account using the link we sent to you, or by resetting your password.\n"
|
||||
msgstr "Sie haben sich bereits auf andere Weise angemeldet, aber Ihr Konto noch nicht bestätigt.\nSie können Ihr Konto über den Link bestätigen, den wir Ihnen gesendet haben, oder durch Zurücksetzen Ihres Passworts.\n"
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:12
|
||||
#: lib/mv_web/controllers/auth_controller.ex:24
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Your email address has now been confirmed"
|
||||
msgstr "Ihre E-Mail-Adresse wurde bestätigt"
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:13
|
||||
#: lib/mv_web/controllers/auth_controller.ex:25
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Your password has successfully been reset"
|
||||
msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
|
||||
|
|
@ -301,7 +301,7 @@ msgstr "ID"
|
|||
msgid "Immutable"
|
||||
msgstr "Unveränderlich"
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:94
|
||||
#: lib/mv_web/components/layouts/navbar.ex:93
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Logout"
|
||||
msgstr "Abmelden"
|
||||
|
|
@ -317,8 +317,8 @@ msgstr "Benutzer*innen auflisten"
|
|||
msgid "Member"
|
||||
msgstr "Mitglied"
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:14
|
||||
#: lib/mv_web/live/member_live/index.ex:8
|
||||
#: lib/mv_web/components/layouts/navbar.ex:19
|
||||
#: lib/mv_web/live/member_live/index.ex:10
|
||||
#: lib/mv_web/live/member_live/index.html.heex:3
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Members"
|
||||
|
|
@ -366,7 +366,7 @@ msgstr "Passwort-Authentifizierung"
|
|||
msgid "Please select a property type first"
|
||||
msgstr "Bitte wählen Sie zuerst einen Eigenschaftstyp"
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:89
|
||||
#: lib/mv_web/components/layouts/navbar.ex:88
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Profil"
|
||||
msgstr "Profil"
|
||||
|
|
@ -411,7 +411,7 @@ msgstr "Alle Mitglieder auswählen"
|
|||
msgid "Select member"
|
||||
msgstr "Mitglied auswählen"
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:92
|
||||
#: lib/mv_web/components/layouts/navbar.ex:91
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Settings"
|
||||
msgstr "Einstellungen"
|
||||
|
|
@ -468,13 +468,13 @@ msgid "Value type"
|
|||
msgstr "Wertetyp"
|
||||
|
||||
#: lib/mv_web/components/table_components.ex:30
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:55
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:57
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "ascending"
|
||||
msgstr "aufsteigend"
|
||||
|
||||
#: lib/mv_web/components/table_components.ex:30
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:56
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:58
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "descending"
|
||||
msgstr "absteigend"
|
||||
|
|
@ -586,14 +586,14 @@ msgstr "Zurück zur Mitgliederliste"
|
|||
msgid "Back to users list"
|
||||
msgstr "Zurück zur Benutzer*innen-Liste"
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:27
|
||||
#: lib/mv_web/components/layouts/navbar.ex:33
|
||||
#: lib/mv_web/components/layouts/navbar.ex:26
|
||||
#: lib/mv_web/components/layouts/navbar.ex:32
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select language"
|
||||
msgstr "Sprache auswählen"
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:40
|
||||
#: lib/mv_web/components/layouts/navbar.ex:60
|
||||
#: lib/mv_web/components/layouts/navbar.ex:39
|
||||
#: lib/mv_web/components/layouts/navbar.ex:59
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Toggle dark mode"
|
||||
msgstr "Dunklen Modus umschalten"
|
||||
|
|
@ -601,15 +601,52 @@ msgstr "Dunklen Modus umschalten"
|
|||
#: lib/mv_web/live/components/search_bar_component.ex:15
|
||||
#: lib/mv_web/live/member_live/index.html.heex:15
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Search..."
|
||||
msgstr "Suchen..."
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:20
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Users"
|
||||
msgstr "Benutzer*innen"
|
||||
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:59
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:63
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Click to sort"
|
||||
msgstr "Klicke um zu sortieren"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:53
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
#: lib/mv_web/live/member_live/index.html.heex:60
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "First name"
|
||||
msgstr "Vorname"
|
||||
|
||||
#~ #: lib/mv_web/auth_overrides.ex:30
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "or"
|
||||
#~ msgstr "oder"
|
||||
#: lib/mv_web/controllers/auth_controller.ex:167
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "An account with this email already exists. Please verify your password to link your OIDC account."
|
||||
msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte verifizieren Sie Ihr Passwort, um Ihr OIDC-Konto zu verknüpfen."
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:77
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unable to authenticate with OIDC. Please try again."
|
||||
msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:152
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unable to sign in. Please try again."
|
||||
msgstr "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:92
|
||||
#: lib/mv_web/controllers/auth_controller.ex:97
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Authentication failed. Please try again."
|
||||
msgstr "Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:124
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cannot update email: This email is already registered to another account. Please change your email in the identity provider."
|
||||
msgstr "E-Mail kann nicht aktualisiert werden: Diese E-Mail-Adresse ist bereits für ein anderes Konto registriert. Bitte ändern Sie Ihre E-Mail-Adresse im Identity-Provider."
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:130
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
|
||||
msgstr "Diese E-Mail-Adresse ist bereits mit einem anderen OIDC-Konto verknüpft. Es können nicht mehrere OIDC-Provider mit demselben Konto verknüpft werden."
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ msgstr ""
|
|||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:193
|
||||
#: lib/mv_web/live/member_live/index.html.heex:200
|
||||
#: lib/mv_web/live/user_live/index.html.heex:65
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Are you sure?"
|
||||
|
|
@ -29,19 +29,19 @@ msgid "Attempting to reconnect"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:25
|
||||
#: lib/mv_web/live/member_live/index.html.heex:138
|
||||
#: lib/mv_web/live/member_live/show.ex:36
|
||||
#: lib/mv_web/live/member_live/index.html.heex:145
|
||||
#: lib/mv_web/live/member_live/show.ex:37
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "City"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:195
|
||||
#: lib/mv_web/live/member_live/index.html.heex:202
|
||||
#: lib/mv_web/live/user_live/index.html.heex:67
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:187
|
||||
#: lib/mv_web/live/member_live/index.html.heex:194
|
||||
#: lib/mv_web/live/user_live/form.ex:109
|
||||
#: lib/mv_web/live/user_live/index.html.heex:59
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -55,8 +55,8 @@ msgid "Edit Member"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:18
|
||||
#: lib/mv_web/live/member_live/index.html.heex:70
|
||||
#: lib/mv_web/live/member_live/show.ex:27
|
||||
#: lib/mv_web/live/member_live/index.html.heex:77
|
||||
#: lib/mv_web/live/member_live/show.ex:28
|
||||
#: lib/mv_web/live/user_live/form.ex:14
|
||||
#: lib/mv_web/live/user_live/index.html.heex:44
|
||||
#: lib/mv_web/live/user_live/show.ex:25
|
||||
|
|
@ -71,8 +71,8 @@ msgid "First Name"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:22
|
||||
#: lib/mv_web/live/member_live/index.html.heex:172
|
||||
#: lib/mv_web/live/member_live/show.ex:33
|
||||
#: lib/mv_web/live/member_live/index.html.heex:179
|
||||
#: lib/mv_web/live/member_live/show.ex:34
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join Date"
|
||||
msgstr ""
|
||||
|
|
@ -88,7 +88,7 @@ msgstr ""
|
|||
msgid "New Member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:184
|
||||
#: lib/mv_web/live/member_live/index.html.heex:191
|
||||
#: lib/mv_web/live/user_live/index.html.heex:56
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Show"
|
||||
|
|
@ -128,8 +128,8 @@ msgid "Exit Date"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:27
|
||||
#: lib/mv_web/live/member_live/index.html.heex:104
|
||||
#: lib/mv_web/live/member_live/show.ex:38
|
||||
#: lib/mv_web/live/member_live/index.html.heex:111
|
||||
#: lib/mv_web/live/member_live/show.ex:39
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "House Number"
|
||||
msgstr ""
|
||||
|
|
@ -147,15 +147,15 @@ msgid "Paid"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:21
|
||||
#: lib/mv_web/live/member_live/index.html.heex:155
|
||||
#: lib/mv_web/live/member_live/show.ex:32
|
||||
#: lib/mv_web/live/member_live/index.html.heex:162
|
||||
#: lib/mv_web/live/member_live/show.ex:33
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Phone Number"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:28
|
||||
#: lib/mv_web/live/member_live/index.html.heex:121
|
||||
#: lib/mv_web/live/member_live/show.ex:39
|
||||
#: lib/mv_web/live/member_live/index.html.heex:128
|
||||
#: lib/mv_web/live/member_live/show.ex:40
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Postal Code"
|
||||
msgstr ""
|
||||
|
|
@ -174,8 +174,8 @@ msgid "Saving..."
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:26
|
||||
#: lib/mv_web/live/member_live/index.html.heex:87
|
||||
#: lib/mv_web/live/member_live/show.ex:37
|
||||
#: lib/mv_web/live/member_live/index.html.heex:94
|
||||
#: lib/mv_web/live/member_live/show.ex:38
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Street"
|
||||
msgstr ""
|
||||
|
|
@ -224,7 +224,7 @@ msgstr ""
|
|||
msgid "update"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:43
|
||||
#: lib/mv_web/controllers/auth_controller.ex:60
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Incorrect email or password"
|
||||
msgstr ""
|
||||
|
|
@ -234,27 +234,27 @@ msgstr ""
|
|||
msgid "Member %{action} successfully"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:14
|
||||
#: lib/mv_web/controllers/auth_controller.ex:26
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You are now signed in"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:56
|
||||
#: lib/mv_web/controllers/auth_controller.ex:186
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You are now signed out"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:37
|
||||
#: lib/mv_web/controllers/auth_controller.ex:85
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You have already signed in another way, but have not confirmed your account.\nYou can confirm your account using the link we sent to you, or by resetting your password.\n"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:12
|
||||
#: lib/mv_web/controllers/auth_controller.ex:24
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Your email address has now been confirmed"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:13
|
||||
#: lib/mv_web/controllers/auth_controller.ex:25
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Your password has successfully been reset"
|
||||
msgstr ""
|
||||
|
|
@ -302,7 +302,7 @@ msgstr ""
|
|||
msgid "Immutable"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:94
|
||||
#: lib/mv_web/components/layouts/navbar.ex:93
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Logout"
|
||||
msgstr ""
|
||||
|
|
@ -318,8 +318,8 @@ msgstr ""
|
|||
msgid "Member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:14
|
||||
#: lib/mv_web/live/member_live/index.ex:8
|
||||
#: lib/mv_web/components/layouts/navbar.ex:19
|
||||
#: lib/mv_web/live/member_live/index.ex:10
|
||||
#: lib/mv_web/live/member_live/index.html.heex:3
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Members"
|
||||
|
|
@ -367,7 +367,7 @@ msgstr ""
|
|||
msgid "Please select a property type first"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:89
|
||||
#: lib/mv_web/components/layouts/navbar.ex:88
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Profil"
|
||||
msgstr ""
|
||||
|
|
@ -412,7 +412,7 @@ msgstr ""
|
|||
msgid "Select member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:92
|
||||
#: lib/mv_web/components/layouts/navbar.ex:91
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Settings"
|
||||
msgstr ""
|
||||
|
|
@ -469,13 +469,13 @@ msgid "Value type"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/table_components.ex:30
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:55
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:57
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "ascending"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/table_components.ex:30
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:56
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:58
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "descending"
|
||||
msgstr ""
|
||||
|
|
@ -587,14 +587,14 @@ msgstr ""
|
|||
msgid "Back to users list"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:27
|
||||
#: lib/mv_web/components/layouts/navbar.ex:33
|
||||
#: lib/mv_web/components/layouts/navbar.ex:26
|
||||
#: lib/mv_web/components/layouts/navbar.ex:32
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select language"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:40
|
||||
#: lib/mv_web/components/layouts/navbar.ex:60
|
||||
#: lib/mv_web/components/layouts/navbar.ex:39
|
||||
#: lib/mv_web/components/layouts/navbar.ex:59
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Toggle dark mode"
|
||||
msgstr ""
|
||||
|
|
@ -608,12 +608,46 @@ msgstr ""
|
|||
#: lib/mv_web/components/layouts/navbar.ex:20
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Users"
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:60
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:59
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:63
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Click to sort"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:53
|
||||
#: lib/mv_web/live/member_live/index.html.heex:60
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "First name"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:167
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "An account with this email already exists. Please verify your password to link your OIDC account."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:77
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unable to authenticate with OIDC. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:152
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unable to sign in. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:92
|
||||
#: lib/mv_web/controllers/auth_controller.ex:97
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Authentication failed. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:124
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cannot update email: This email is already registered to another account. Please change your email in the identity provider."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:130
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ msgstr ""
|
|||
msgid "Need an account?"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:268
|
||||
#, elixir-autogen
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -59,5 +61,78 @@ msgstr ""
|
|||
msgid "Your password has successfully been reset"
|
||||
msgstr ""
|
||||
|
||||
#~ msgid "Sign in with Rauthy"
|
||||
#~ msgstr "Sign in with Vereinscloud"
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:254
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:289
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:163
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Incorrect password. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:37
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid session. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:281
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Link Account"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:252
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Link OIDC Account"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:280
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Linking..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:40
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Session expired. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:209
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:76
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Account activated! Redirecting to complete sign-in..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:119
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:123
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to link account. Please try again or contact support."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:108
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:98
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This OIDC account is already linked to another user. Please contact support."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:235
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Language selection"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:242
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select language"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ msgstr ""
|
|||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:193
|
||||
#: lib/mv_web/live/member_live/index.html.heex:200
|
||||
#: lib/mv_web/live/user_live/index.html.heex:65
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Are you sure?"
|
||||
|
|
@ -29,19 +29,19 @@ msgid "Attempting to reconnect"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:25
|
||||
#: lib/mv_web/live/member_live/index.html.heex:138
|
||||
#: lib/mv_web/live/member_live/show.ex:36
|
||||
#: lib/mv_web/live/member_live/index.html.heex:145
|
||||
#: lib/mv_web/live/member_live/show.ex:37
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "City"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:195
|
||||
#: lib/mv_web/live/member_live/index.html.heex:202
|
||||
#: lib/mv_web/live/user_live/index.html.heex:67
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:187
|
||||
#: lib/mv_web/live/member_live/index.html.heex:194
|
||||
#: lib/mv_web/live/user_live/form.ex:109
|
||||
#: lib/mv_web/live/user_live/index.html.heex:59
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -55,8 +55,8 @@ msgid "Edit Member"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:18
|
||||
#: lib/mv_web/live/member_live/index.html.heex:70
|
||||
#: lib/mv_web/live/member_live/show.ex:27
|
||||
#: lib/mv_web/live/member_live/index.html.heex:77
|
||||
#: lib/mv_web/live/member_live/show.ex:28
|
||||
#: lib/mv_web/live/user_live/form.ex:14
|
||||
#: lib/mv_web/live/user_live/index.html.heex:44
|
||||
#: lib/mv_web/live/user_live/show.ex:25
|
||||
|
|
@ -71,8 +71,8 @@ msgid "First Name"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:22
|
||||
#: lib/mv_web/live/member_live/index.html.heex:172
|
||||
#: lib/mv_web/live/member_live/show.ex:33
|
||||
#: lib/mv_web/live/member_live/index.html.heex:179
|
||||
#: lib/mv_web/live/member_live/show.ex:34
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join Date"
|
||||
msgstr ""
|
||||
|
|
@ -88,7 +88,7 @@ msgstr ""
|
|||
msgid "New Member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:184
|
||||
#: lib/mv_web/live/member_live/index.html.heex:191
|
||||
#: lib/mv_web/live/user_live/index.html.heex:56
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Show"
|
||||
|
|
@ -128,8 +128,8 @@ msgid "Exit Date"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:27
|
||||
#: lib/mv_web/live/member_live/index.html.heex:104
|
||||
#: lib/mv_web/live/member_live/show.ex:38
|
||||
#: lib/mv_web/live/member_live/index.html.heex:111
|
||||
#: lib/mv_web/live/member_live/show.ex:39
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "House Number"
|
||||
msgstr ""
|
||||
|
|
@ -147,15 +147,15 @@ msgid "Paid"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:21
|
||||
#: lib/mv_web/live/member_live/index.html.heex:155
|
||||
#: lib/mv_web/live/member_live/show.ex:32
|
||||
#: lib/mv_web/live/member_live/index.html.heex:162
|
||||
#: lib/mv_web/live/member_live/show.ex:33
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Phone Number"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:28
|
||||
#: lib/mv_web/live/member_live/index.html.heex:121
|
||||
#: lib/mv_web/live/member_live/show.ex:39
|
||||
#: lib/mv_web/live/member_live/index.html.heex:128
|
||||
#: lib/mv_web/live/member_live/show.ex:40
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Postal Code"
|
||||
msgstr ""
|
||||
|
|
@ -174,8 +174,8 @@ msgid "Saving..."
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:26
|
||||
#: lib/mv_web/live/member_live/index.html.heex:87
|
||||
#: lib/mv_web/live/member_live/show.ex:37
|
||||
#: lib/mv_web/live/member_live/index.html.heex:94
|
||||
#: lib/mv_web/live/member_live/show.ex:38
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Street"
|
||||
msgstr ""
|
||||
|
|
@ -224,7 +224,7 @@ msgstr ""
|
|||
msgid "update"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:43
|
||||
#: lib/mv_web/controllers/auth_controller.ex:60
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Incorrect email or password"
|
||||
msgstr ""
|
||||
|
|
@ -234,27 +234,27 @@ msgstr ""
|
|||
msgid "Member %{action} successfully"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:14
|
||||
#: lib/mv_web/controllers/auth_controller.ex:26
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You are now signed in"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:56
|
||||
#: lib/mv_web/controllers/auth_controller.ex:186
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You are now signed out"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:37
|
||||
#: lib/mv_web/controllers/auth_controller.ex:85
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You have already signed in another way, but have not confirmed your account.\nYou can confirm your account using the link we sent to you, or by resetting your password.\n"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:12
|
||||
#: lib/mv_web/controllers/auth_controller.ex:24
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Your email address has now been confirmed"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:13
|
||||
#: lib/mv_web/controllers/auth_controller.ex:25
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Your password has successfully been reset"
|
||||
msgstr ""
|
||||
|
|
@ -302,7 +302,7 @@ msgstr ""
|
|||
msgid "Immutable"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:94
|
||||
#: lib/mv_web/components/layouts/navbar.ex:93
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Logout"
|
||||
msgstr ""
|
||||
|
|
@ -318,8 +318,8 @@ msgstr ""
|
|||
msgid "Member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:14
|
||||
#: lib/mv_web/live/member_live/index.ex:8
|
||||
#: lib/mv_web/components/layouts/navbar.ex:19
|
||||
#: lib/mv_web/live/member_live/index.ex:10
|
||||
#: lib/mv_web/live/member_live/index.html.heex:3
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Members"
|
||||
|
|
@ -367,7 +367,7 @@ msgstr ""
|
|||
msgid "Please select a property type first"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:89
|
||||
#: lib/mv_web/components/layouts/navbar.ex:88
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Profil"
|
||||
msgstr ""
|
||||
|
|
@ -412,7 +412,7 @@ msgstr ""
|
|||
msgid "Select member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:92
|
||||
#: lib/mv_web/components/layouts/navbar.ex:91
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Settings"
|
||||
msgstr ""
|
||||
|
|
@ -469,13 +469,13 @@ msgid "Value type"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/table_components.ex:30
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:55
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:57
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "ascending"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/table_components.ex:30
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:56
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:58
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "descending"
|
||||
msgstr ""
|
||||
|
|
@ -555,17 +555,99 @@ 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/components/sort_header_component.ex:60
|
||||
#: lib/mv_web/live/user_live/show.ex:30
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Linked Member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex:41
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Linked User"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/show.ex:40
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No member linked"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex:51
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No user linked"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex:14
|
||||
#: lib/mv_web/live/member_live/show.ex:16
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Back to members list"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/user_live/show.ex:13
|
||||
#: lib/mv_web/live/user_live/show.ex:15
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Back to users list"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:26
|
||||
#: lib/mv_web/components/layouts/navbar.ex:32
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Select language"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:39
|
||||
#: lib/mv_web/components/layouts/navbar.ex:59
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Toggle dark mode"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/search_bar_component.ex:15
|
||||
#: lib/mv_web/live/member_live/index.html.heex:15
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Search..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:20
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Users"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:59
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:63
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Click to sort"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:53
|
||||
#: lib/mv_web/live/member_live/index.html.heex:60
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "First name"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/auth_overrides.ex:30
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "or"
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/controllers/auth_controller.ex:167
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "An account with this email already exists. Please verify your password to link your OIDC account."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:77
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unable to authenticate with OIDC. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:152
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Unable to sign in. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:92
|
||||
#: lib/mv_web/controllers/auth_controller.ex:97
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Authentication failed. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:124
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cannot update email: This email is already registered to another account. Please change your email in the identity provider."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/auth_controller.ex:130
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This email is already linked to a different OIDC account. Cannot link multiple OIDC providers to the same account."
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
defmodule Mv.Repo.Migrations.AddTrigramToMembers do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
# activate trigram-extension
|
||||
execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
|
||||
|
||||
# -------------------------------------------------
|
||||
# Trigram‑Indizes (GIN) for fields we want to search in
|
||||
# -------------------------------------------------
|
||||
#
|
||||
# `gin_trgm_ops` ist the operator-class-name
|
||||
#
|
||||
|
||||
execute("""
|
||||
CREATE INDEX members_first_name_trgm_idx
|
||||
ON members
|
||||
USING GIN (first_name gin_trgm_ops);
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE INDEX members_last_name_trgm_idx
|
||||
ON members
|
||||
USING GIN (last_name gin_trgm_ops);
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE INDEX members_email_trgm_idx
|
||||
ON members
|
||||
USING GIN (email gin_trgm_ops);
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE INDEX members_city_trgm_idx
|
||||
ON members
|
||||
USING GIN (city gin_trgm_ops);
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE INDEX members_street_trgm_idx
|
||||
ON members
|
||||
USING GIN (street gin_trgm_ops);
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE INDEX members_notes_trgm_idx
|
||||
ON members
|
||||
USING GIN (notes gin_trgm_ops);
|
||||
""")
|
||||
end
|
||||
|
||||
def down do
|
||||
execute("DROP INDEX IF EXISTS members_first_name_trgm_idx;")
|
||||
execute("DROP INDEX IF EXISTS members_last_name_trgm_idx;")
|
||||
execute("DROP INDEX IF EXISTS members_email_trgm_idx;")
|
||||
execute("DROP INDEX IF EXISTS members_city_trgm_idx;")
|
||||
execute("DROP INDEX IF EXISTS members_street_trgm_idx;")
|
||||
execute("DROP INDEX IF EXISTS members_notes_trgm_idx;")
|
||||
end
|
||||
end
|
||||
199
priv/resource_snapshots/repo/members/20251001141005.json
Normal file
199
priv/resource_snapshots/repo/members/20251001141005.json
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"uuid_generate_v7()\")",
|
||||
"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": "first_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "last_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "email",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "birth_date",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "paid",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "phone_number",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "join_date",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "exit_date",
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "notes",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "city",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "street",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "house_number",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "postal_code",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "search_vector",
|
||||
"type": "tsvector"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "9019AD59832AB926899B6A871A368CF65F757533795E4E38D5C0EE6AE58BE070",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "members"
|
||||
}
|
||||
265
test/accounts/user_authentication_test.exs
Normal file
265
test/accounts/user_authentication_test.exs
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
defmodule Mv.Accounts.UserAuthenticationTest do
|
||||
@moduledoc """
|
||||
Tests for user authentication and identification mechanisms.
|
||||
|
||||
This test suite verifies that:
|
||||
- Password login correctly identifies users via email
|
||||
- OIDC login correctly identifies users via oidc_id
|
||||
- Session identifiers work as expected for both authentication methods
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
require Ash.Query
|
||||
|
||||
describe "Password authentication user identification" do
|
||||
@tag :test_proposal
|
||||
test "password login uses email as identifier" do
|
||||
# Create a user with password authentication (no oidc_id)
|
||||
user =
|
||||
create_test_user(%{
|
||||
email: "password.user@example.com",
|
||||
password: "securepassword123",
|
||||
oidc_id: nil
|
||||
})
|
||||
|
||||
# Verify that the user can be found by email
|
||||
email_to_find = to_string(user.email)
|
||||
|
||||
{:ok, users} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(email == ^email_to_find)
|
||||
|> Ash.read()
|
||||
|
||||
assert length(users) == 1
|
||||
found_user = List.first(users)
|
||||
assert found_user.id == user.id
|
||||
assert to_string(found_user.email) == "password.user@example.com"
|
||||
assert is_nil(found_user.oidc_id)
|
||||
end
|
||||
|
||||
@tag :test_proposal
|
||||
test "password authentication uses email as identity_field" do
|
||||
# Verify the configuration: password strategy should use email as identity_field
|
||||
# This test checks the AshAuthentication configuration
|
||||
|
||||
strategies = AshAuthentication.Info.authentication_strategies(Mv.Accounts.User)
|
||||
password_strategy = Enum.find(strategies, fn s -> s.name == :password end)
|
||||
|
||||
assert password_strategy != nil
|
||||
assert password_strategy.identity_field == :email
|
||||
end
|
||||
|
||||
@tag :test_proposal
|
||||
test "multiple users can exist with different emails" do
|
||||
user1 =
|
||||
create_test_user(%{
|
||||
email: "user1@example.com",
|
||||
password: "password123",
|
||||
oidc_id: nil
|
||||
})
|
||||
|
||||
user2 =
|
||||
create_test_user(%{
|
||||
email: "user2@example.com",
|
||||
password: "password456",
|
||||
oidc_id: nil
|
||||
})
|
||||
|
||||
assert user1.id != user2.id
|
||||
assert to_string(user1.email) != to_string(user2.email)
|
||||
end
|
||||
|
||||
@tag :test_proposal
|
||||
test "users with same password but different emails are separate accounts" do
|
||||
same_password = "shared_password_123"
|
||||
|
||||
user1 =
|
||||
create_test_user(%{
|
||||
email: "alice@example.com",
|
||||
password: same_password,
|
||||
oidc_id: nil
|
||||
})
|
||||
|
||||
user2 =
|
||||
create_test_user(%{
|
||||
email: "bob@example.com",
|
||||
password: same_password,
|
||||
oidc_id: nil
|
||||
})
|
||||
|
||||
# Different users despite same password
|
||||
assert user1.id != user2.id
|
||||
|
||||
# Both passwords should hash to different values (bcrypt uses salt)
|
||||
assert user1.hashed_password != user2.hashed_password
|
||||
end
|
||||
end
|
||||
|
||||
describe "OIDC authentication user identification" do
|
||||
@tag :test_proposal
|
||||
test "OIDC login with matching oidc_id finds correct user" do
|
||||
# Create user with OIDC authentication
|
||||
user =
|
||||
create_test_user(%{
|
||||
email: "oidc.user@example.com",
|
||||
oidc_id: "oidc_identifier_12345"
|
||||
})
|
||||
|
||||
# Simulate OIDC callback
|
||||
user_info = %{
|
||||
"sub" => "oidc_identifier_12345",
|
||||
"preferred_username" => "oidc.user@example.com"
|
||||
}
|
||||
|
||||
# Use sign_in_with_rauthy to find user by oidc_id
|
||||
# Note: This test will FAIL until we implement the security fix
|
||||
# that changes the filter from email to oidc_id
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
case result do
|
||||
{:ok, [found_user]} ->
|
||||
assert found_user.id == user.id
|
||||
assert found_user.oidc_id == "oidc_identifier_12345"
|
||||
|
||||
{:ok, []} ->
|
||||
flunk("User should be found by oidc_id")
|
||||
|
||||
{:error, error} ->
|
||||
flunk("Unexpected error: #{inspect(error)}")
|
||||
end
|
||||
end
|
||||
|
||||
@tag :test_proposal
|
||||
test "OIDC login creates new user when both email and oidc_id are new" do
|
||||
# Completely new user from OIDC provider
|
||||
user_info = %{
|
||||
"sub" => "brand_new_oidc_789",
|
||||
"preferred_username" => "newuser@example.com"
|
||||
}
|
||||
|
||||
# Should create via register_with_rauthy
|
||||
{:ok, new_user} =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
assert to_string(new_user.email) == "newuser@example.com"
|
||||
assert new_user.oidc_id == "brand_new_oidc_789"
|
||||
assert is_nil(new_user.hashed_password)
|
||||
end
|
||||
|
||||
@tag :test_proposal
|
||||
test "OIDC user can be uniquely identified by oidc_id" do
|
||||
user1 =
|
||||
create_test_user(%{
|
||||
email: "user1@example.com",
|
||||
oidc_id: "oidc_unique_1"
|
||||
})
|
||||
|
||||
user2 =
|
||||
create_test_user(%{
|
||||
email: "user2@example.com",
|
||||
oidc_id: "oidc_unique_2"
|
||||
})
|
||||
|
||||
# Find by oidc_id
|
||||
{:ok, users1} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(oidc_id == "oidc_unique_1")
|
||||
|> Ash.read()
|
||||
|
||||
{:ok, users2} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(oidc_id == "oidc_unique_2")
|
||||
|> Ash.read()
|
||||
|
||||
assert length(users1) == 1
|
||||
assert length(users2) == 1
|
||||
assert List.first(users1).id == user1.id
|
||||
assert List.first(users2).id == user2.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "Mixed authentication scenarios" do
|
||||
@tag :test_proposal
|
||||
test "user with oidc_id cannot be found by email-only query in sign_in_with_rauthy" do
|
||||
# This test verifies the security fix: sign_in_with_rauthy should NOT
|
||||
# match users by email, only by oidc_id
|
||||
|
||||
_user =
|
||||
create_test_user(%{
|
||||
email: "secure@example.com",
|
||||
oidc_id: "secure_oidc_999"
|
||||
})
|
||||
|
||||
# Try to sign in with DIFFERENT oidc_id but SAME email
|
||||
user_info = %{
|
||||
# Different oidc_id!
|
||||
"sub" => "attacker_oidc_888",
|
||||
# Same email
|
||||
"preferred_username" => "secure@example.com"
|
||||
}
|
||||
|
||||
# Should NOT find the user (security requirement)
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
# Either returns empty list OR authentication error - both mean "user not found"
|
||||
case result do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||
:ok
|
||||
|
||||
other ->
|
||||
flunk("sign_in_with_rauthy should not match by email alone, got: #{inspect(other)}")
|
||||
end
|
||||
end
|
||||
|
||||
@tag :test_proposal
|
||||
test "password user (oidc_id=nil) is not found by sign_in_with_rauthy" do
|
||||
# Create a password-only user
|
||||
_user =
|
||||
create_test_user(%{
|
||||
email: "password.only@example.com",
|
||||
password: "securepass123",
|
||||
oidc_id: nil
|
||||
})
|
||||
|
||||
# Try OIDC sign-in with this email
|
||||
user_info = %{
|
||||
"sub" => "new_oidc_777",
|
||||
"preferred_username" => "password.only@example.com"
|
||||
}
|
||||
|
||||
# Should NOT find the user because oidc_id is nil
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
# Either returns empty list OR authentication error - both mean "user not found"
|
||||
case result do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||
:ok
|
||||
|
||||
other ->
|
||||
flunk(
|
||||
"Password-only user should not be found by sign_in_with_rauthy, got: #{inspect(other)}"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
443
test/membership/fuzzy_search_test.exs
Normal file
443
test/membership/fuzzy_search_test.exs
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
defmodule Mv.Membership.FuzzySearchTest do
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
test "fuzzy_search/2 function exists" do
|
||||
assert function_exported?(Mv.Membership.Member, :fuzzy_search, 2)
|
||||
end
|
||||
|
||||
test "fuzzy_search returns only John Doe by fuzzy query 'john'" do
|
||||
{:ok, john} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
email: "john.doe@example.com"
|
||||
})
|
||||
|
||||
{:ok, _jane} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Adriana",
|
||||
last_name: "Smith",
|
||||
email: "adriana.smith@example.com"
|
||||
})
|
||||
|
||||
{:ok, alice} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Alice",
|
||||
last_name: "Johnson",
|
||||
email: "alice.johnson@example.com"
|
||||
})
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{
|
||||
query: "john"
|
||||
})
|
||||
|> Ash.read!()
|
||||
|
||||
assert Enum.map(result, & &1.id) == [john.id, alice.id]
|
||||
end
|
||||
|
||||
test "fuzzy_search finds 'Thomas' when searching misspelled 'tomas'" do
|
||||
{:ok, thomas} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Thomas",
|
||||
last_name: "Doe",
|
||||
email: "john.doe@example.com"
|
||||
})
|
||||
|
||||
{:ok, jane} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Jane",
|
||||
last_name: "Smith",
|
||||
email: "jane.smith@example.com"
|
||||
})
|
||||
|
||||
{:ok, _alice} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Alice",
|
||||
last_name: "Johnson",
|
||||
email: "alice.johnson@example.com"
|
||||
})
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{
|
||||
query: "tomas"
|
||||
})
|
||||
|> Ash.read!()
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert thomas.id in ids
|
||||
refute jane.id in ids
|
||||
assert length(ids) >= 1
|
||||
end
|
||||
|
||||
test "empty query returns all members" do
|
||||
{:ok, a} =
|
||||
Mv.Membership.create_member(%{first_name: "A", last_name: "One", email: "a1@example.com"})
|
||||
|
||||
{:ok, b} =
|
||||
Mv.Membership.create_member(%{first_name: "B", last_name: "Two", email: "b2@example.com"})
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: ""})
|
||||
|> Ash.read!()
|
||||
|
||||
assert Enum.sort(Enum.map(result, & &1.id))
|
||||
|> Enum.uniq()
|
||||
|> Enum.sort()
|
||||
|> Enum.all?(fn id -> id in [a.id, b.id] end)
|
||||
end
|
||||
|
||||
test "substring numeric search matches postal_code mid-string" do
|
||||
{:ok, m1} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Num",
|
||||
last_name: "One",
|
||||
email: "n1@example.com",
|
||||
postal_code: "12345"
|
||||
})
|
||||
|
||||
{:ok, _m2} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Num",
|
||||
last_name: "Two",
|
||||
email: "n2@example.com",
|
||||
postal_code: "67890"
|
||||
})
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "345"})
|
||||
|> Ash.read!()
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert m1.id in ids
|
||||
end
|
||||
|
||||
test "substring numeric search matches house_number mid-string" do
|
||||
{:ok, m1} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Home",
|
||||
last_name: "One",
|
||||
email: "h1@example.com",
|
||||
house_number: "A345B"
|
||||
})
|
||||
|
||||
{:ok, _m2} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Home",
|
||||
last_name: "Two",
|
||||
email: "h2@example.com",
|
||||
house_number: "77"
|
||||
})
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "345"})
|
||||
|> Ash.read!()
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert m1.id in ids
|
||||
end
|
||||
|
||||
test "fuzzy matches street misspelling" do
|
||||
{:ok, s1} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Road",
|
||||
last_name: "Test",
|
||||
email: "s1@example.com",
|
||||
street: "Main Street"
|
||||
})
|
||||
|
||||
{:ok, _s2} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Road",
|
||||
last_name: "Other",
|
||||
email: "s2@example.com",
|
||||
street: "Second Avenue"
|
||||
})
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "mainn"})
|
||||
|> Ash.read!()
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert s1.id in ids
|
||||
end
|
||||
|
||||
test "substring in city matches mid-string" do
|
||||
{:ok, b} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "City",
|
||||
last_name: "One",
|
||||
email: "city1@example.com",
|
||||
city: "Berlin"
|
||||
})
|
||||
|
||||
{:ok, _m} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "City",
|
||||
last_name: "Two",
|
||||
email: "city2@example.com",
|
||||
city: "München"
|
||||
})
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "erl"})
|
||||
|> Ash.read!()
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert b.id in ids
|
||||
end
|
||||
|
||||
test "blank character handling: query with spaces matches full name" do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
email: "john.doe@example.com"
|
||||
})
|
||||
|
||||
{:ok, _other} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Jane",
|
||||
last_name: "Smith",
|
||||
email: "jane.smith@example.com"
|
||||
})
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "john doe"})
|
||||
|> Ash.read!()
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert member.id in ids
|
||||
end
|
||||
|
||||
test "blank character handling: query with multiple spaces is handled" do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Mary",
|
||||
last_name: "Jane",
|
||||
email: "mary.jane@example.com"
|
||||
})
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "mary jane"})
|
||||
|> Ash.read!()
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert member.id in ids
|
||||
end
|
||||
|
||||
test "special character handling: @ symbol in query matches email" do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test.user@example.com"
|
||||
})
|
||||
|
||||
{:ok, _other} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Other",
|
||||
last_name: "Person",
|
||||
email: "other.person@different.org"
|
||||
})
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "example"})
|
||||
|> Ash.read!()
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert member.id in ids
|
||||
end
|
||||
|
||||
test "special character handling: dot in query matches email" do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Dot",
|
||||
last_name: "Test",
|
||||
email: "dot.test@example.com"
|
||||
})
|
||||
|
||||
{:ok, _other} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "No",
|
||||
last_name: "Dot",
|
||||
email: "nodot@example.com"
|
||||
})
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "dot.test"})
|
||||
|> Ash.read!()
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert member.id in ids
|
||||
end
|
||||
|
||||
test "special character handling: hyphen in query matches data" do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Mary-Jane",
|
||||
last_name: "Watson",
|
||||
email: "mary.jane@example.com"
|
||||
})
|
||||
|
||||
{:ok, _other} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Mary",
|
||||
last_name: "Smith",
|
||||
email: "mary.smith@example.com"
|
||||
})
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "mary-jane"})
|
||||
|> Ash.read!()
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert member.id in ids
|
||||
end
|
||||
|
||||
test "unicode character handling: umlaut ö in query matches data" do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Jörg",
|
||||
last_name: "Schmidt",
|
||||
email: "joerg.schmidt@example.com"
|
||||
})
|
||||
|
||||
{:ok, _other} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "John",
|
||||
last_name: "Smith",
|
||||
email: "john.smith@example.com"
|
||||
})
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "jörg"})
|
||||
|> Ash.read!()
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert member.id in ids
|
||||
end
|
||||
|
||||
test "unicode character handling: umlaut ä in query matches data" do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Märta",
|
||||
last_name: "Andersson",
|
||||
email: "maerta.andersson@example.com"
|
||||
})
|
||||
|
||||
{:ok, _other} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Marta",
|
||||
last_name: "Johnson",
|
||||
email: "marta.johnson@example.com"
|
||||
})
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "märta"})
|
||||
|> Ash.read!()
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert member.id in ids
|
||||
end
|
||||
|
||||
test "unicode character handling: umlaut ü in query matches data" do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Günther",
|
||||
last_name: "Müller",
|
||||
email: "guenther.mueller@example.com"
|
||||
})
|
||||
|
||||
{:ok, _other} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Gunter",
|
||||
last_name: "Miller",
|
||||
email: "gunter.miller@example.com"
|
||||
})
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "müller"})
|
||||
|> Ash.read!()
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert member.id in ids
|
||||
end
|
||||
|
||||
test "unicode character handling: query without umlaut matches data with umlaut" do
|
||||
{:ok, member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Müller",
|
||||
last_name: "Schmidt",
|
||||
email: "mueller.schmidt@example.com"
|
||||
})
|
||||
|
||||
{:ok, _other} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Miller",
|
||||
last_name: "Smith",
|
||||
email: "miller.smith@example.com"
|
||||
})
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: "muller"})
|
||||
|> Ash.read!()
|
||||
|
||||
ids = Enum.map(result, & &1.id)
|
||||
assert member.id in ids
|
||||
end
|
||||
|
||||
test "very long search strings: handles long query without error" do
|
||||
{:ok, _member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test@example.com"
|
||||
})
|
||||
|
||||
long_query = String.duplicate("a", 1000)
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: long_query})
|
||||
|> Ash.read!()
|
||||
|
||||
# Should not crash, may return empty or some results
|
||||
assert is_list(result)
|
||||
end
|
||||
|
||||
test "very long search strings: handles extremely long query" do
|
||||
{:ok, _member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Test",
|
||||
last_name: "User",
|
||||
email: "test@example.com"
|
||||
})
|
||||
|
||||
very_long_query = String.duplicate("test query ", 1000)
|
||||
|
||||
result =
|
||||
Mv.Membership.Member
|
||||
|> Mv.Membership.Member.fuzzy_search(%{query: very_long_query})
|
||||
|> Ash.read!()
|
||||
|
||||
# Should not crash, may return empty or some results
|
||||
assert is_list(result)
|
||||
end
|
||||
end
|
||||
415
test/mv_web/controllers/oidc_e2e_flow_test.exs
Normal file
415
test/mv_web/controllers/oidc_e2e_flow_test.exs
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
defmodule MvWeb.OidcE2EFlowTest do
|
||||
@moduledoc """
|
||||
End-to-end tests for OIDC authentication flows.
|
||||
|
||||
These tests simulate the complete user journey through OIDC authentication,
|
||||
including account linking scenarios.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
require Ash.Query
|
||||
|
||||
describe "E2E: New OIDC user registration" do
|
||||
test "new user can register via OIDC", %{conn: _conn} do
|
||||
# Simulate OIDC callback for brand new user
|
||||
user_info = %{
|
||||
"sub" => "new_oidc_user_123",
|
||||
"preferred_username" => "newuser@example.com"
|
||||
}
|
||||
|
||||
# Call register action
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
assert {:ok, new_user} = result
|
||||
assert to_string(new_user.email) == "newuser@example.com"
|
||||
assert new_user.oidc_id == "new_oidc_user_123"
|
||||
assert is_nil(new_user.hashed_password)
|
||||
|
||||
# Verify user can be found by oidc_id
|
||||
{:ok, [found_user]} =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
assert found_user.id == new_user.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "E2E: Existing OIDC user sign-in" do
|
||||
test "existing OIDC user can sign in and email updates", %{conn: _conn} do
|
||||
# Create OIDC user
|
||||
user =
|
||||
create_test_user(%{
|
||||
email: "oldmail@example.com",
|
||||
oidc_id: "oidc_existing_999"
|
||||
})
|
||||
|
||||
# User changed email at OIDC provider
|
||||
updated_user_info = %{
|
||||
"sub" => "oidc_existing_999",
|
||||
"preferred_username" => "newmail@example.com"
|
||||
}
|
||||
|
||||
# Register (upsert) with new email
|
||||
{:ok, updated_user} =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: updated_user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
# Same user, updated email
|
||||
assert updated_user.id == user.id
|
||||
assert to_string(updated_user.email) == "newmail@example.com"
|
||||
assert updated_user.oidc_id == "oidc_existing_999"
|
||||
end
|
||||
end
|
||||
|
||||
describe "E2E: OIDC with existing password account (Email Collision)" do
|
||||
test "OIDC registration with password account email triggers PasswordVerificationRequired",
|
||||
%{conn: _conn} do
|
||||
# Step 1: Create a password-only user
|
||||
password_user =
|
||||
create_test_user(%{
|
||||
email: "collision@example.com",
|
||||
password: "mypassword123",
|
||||
oidc_id: nil
|
||||
})
|
||||
|
||||
# Step 2: Try to register via OIDC with same email
|
||||
user_info = %{
|
||||
"sub" => "oidc_new_777",
|
||||
"preferred_username" => "collision@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
# Step 3: Should fail with PasswordVerificationRequired
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||
|
||||
password_error =
|
||||
Enum.find(errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
|
||||
assert password_error != nil
|
||||
assert password_error.user_id == password_user.id
|
||||
assert password_error.oidc_user_info["sub"] == "oidc_new_777"
|
||||
assert password_error.oidc_user_info["preferred_username"] == "collision@example.com"
|
||||
end
|
||||
|
||||
test "full E2E flow: OIDC collision -> password verification -> account linked",
|
||||
%{conn: _conn} do
|
||||
# Step 1: Create password user
|
||||
password_user =
|
||||
create_test_user(%{
|
||||
email: "full@example.com",
|
||||
password: "testpass123",
|
||||
oidc_id: nil
|
||||
})
|
||||
|
||||
# Step 2: OIDC registration triggers error
|
||||
user_info = %{
|
||||
"sub" => "oidc_link_888",
|
||||
"preferred_username" => "full@example.com"
|
||||
}
|
||||
|
||||
{:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
# Extract the error
|
||||
password_error =
|
||||
Enum.find(errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
|
||||
assert password_error != nil
|
||||
|
||||
# Step 3: User verifies password (this would happen in LiveView)
|
||||
# Here we simulate successful password verification
|
||||
|
||||
# Step 4: Link OIDC account after verification
|
||||
{:ok, linked_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(id == ^password_user.id)
|
||||
|> Ash.read_one!()
|
||||
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
||||
oidc_id: user_info["sub"],
|
||||
oidc_user_info: user_info
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
# Verify account is now linked
|
||||
assert linked_user.id == password_user.id
|
||||
assert linked_user.oidc_id == "oidc_link_888"
|
||||
assert to_string(linked_user.email) == "full@example.com"
|
||||
# Password should still exist
|
||||
assert linked_user.hashed_password == password_user.hashed_password
|
||||
|
||||
# Step 5: User can now sign in via OIDC
|
||||
{:ok, [signed_in_user]} =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
assert signed_in_user.id == password_user.id
|
||||
assert signed_in_user.oidc_id == "oidc_link_888"
|
||||
end
|
||||
|
||||
test "E2E: OIDC collision with different email at provider updates email after linking",
|
||||
%{conn: _conn} do
|
||||
# Password user with old email
|
||||
password_user =
|
||||
create_test_user(%{
|
||||
email: "old@example.com",
|
||||
password: "pass123",
|
||||
oidc_id: nil
|
||||
})
|
||||
|
||||
# OIDC provider has new email
|
||||
user_info = %{
|
||||
"sub" => "oidc_new_email_555",
|
||||
"preferred_username" => "old@example.com"
|
||||
}
|
||||
|
||||
# Collision detected
|
||||
{:error, %Ash.Error.Invalid{}} =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
# After password verification, link with OIDC info that has NEW email
|
||||
updated_user_info = %{
|
||||
"sub" => "oidc_new_email_555",
|
||||
"preferred_username" => "new@example.com"
|
||||
}
|
||||
|
||||
{:ok, linked_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(id == ^password_user.id)
|
||||
|> Ash.read_one!()
|
||||
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
||||
oidc_id: updated_user_info["sub"],
|
||||
oidc_user_info: updated_user_info
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
# Email should be updated to match OIDC provider
|
||||
assert to_string(linked_user.email) == "new@example.com"
|
||||
assert linked_user.oidc_id == "oidc_new_email_555"
|
||||
end
|
||||
end
|
||||
|
||||
describe "E2E: OIDC with linked member" do
|
||||
test "E2E: email sync to member when linking OIDC to password account", %{conn: _conn} do
|
||||
# Create member
|
||||
member =
|
||||
Ash.Seed.seed!(Mv.Membership.Member, %{
|
||||
email: "member@example.com",
|
||||
first_name: "Test",
|
||||
last_name: "User"
|
||||
})
|
||||
|
||||
# Create password user linked to member
|
||||
password_user =
|
||||
Ash.Seed.seed!(Mv.Accounts.User, %{
|
||||
email: "member@example.com",
|
||||
hashed_password: "dummy_hash",
|
||||
oidc_id: nil,
|
||||
member_id: member.id
|
||||
})
|
||||
|
||||
# OIDC registration with same email
|
||||
user_info = %{
|
||||
"sub" => "oidc_member_333",
|
||||
"preferred_username" => "member@example.com"
|
||||
}
|
||||
|
||||
# Collision detected
|
||||
{:error, %Ash.Error.Invalid{}} =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
# After password verification, link OIDC with NEW email
|
||||
updated_user_info = %{
|
||||
"sub" => "oidc_member_333",
|
||||
"preferred_username" => "newmember@example.com"
|
||||
}
|
||||
|
||||
{:ok, linked_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(id == ^password_user.id)
|
||||
|> Ash.read_one!()
|
||||
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
||||
oidc_id: updated_user_info["sub"],
|
||||
oidc_user_info: updated_user_info
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
# User email updated
|
||||
assert to_string(linked_user.email) == "newmember@example.com"
|
||||
|
||||
# Member email should be synced
|
||||
{:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id)
|
||||
assert to_string(updated_member.email) == "newmember@example.com"
|
||||
end
|
||||
end
|
||||
|
||||
describe "E2E: Security scenarios" do
|
||||
test "E2E: password-only user cannot be accessed via OIDC without password", %{conn: _conn} do
|
||||
# Create password user
|
||||
_password_user =
|
||||
create_test_user(%{
|
||||
email: "secure@example.com",
|
||||
password: "securepass123",
|
||||
oidc_id: nil
|
||||
})
|
||||
|
||||
# Attacker tries to sign in via OIDC with same email
|
||||
user_info = %{
|
||||
"sub" => "attacker_oidc_666",
|
||||
"preferred_username" => "secure@example.com"
|
||||
}
|
||||
|
||||
# Sign-in should fail (no matching oidc_id)
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
case result do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
:ok
|
||||
|
||||
other ->
|
||||
flunk("Expected no access, got: #{inspect(other)}")
|
||||
end
|
||||
|
||||
# Registration should trigger password requirement
|
||||
{:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
assert Enum.any?(errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
end
|
||||
|
||||
test "E2E: user with oidc_id cannot be hijacked by different OIDC provider", %{conn: _conn} do
|
||||
# User linked to OIDC provider A
|
||||
_user =
|
||||
create_test_user(%{
|
||||
email: "linked@example.com",
|
||||
oidc_id: "provider_a_123"
|
||||
})
|
||||
|
||||
# Attacker tries to register with OIDC provider B using same email
|
||||
user_info = %{
|
||||
"sub" => "provider_b_456",
|
||||
"preferred_username" => "linked@example.com"
|
||||
}
|
||||
|
||||
# Should trigger hard error (not PasswordVerificationRequired)
|
||||
{:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
# Should have hard error about "already linked to a different OIDC account"
|
||||
assert Enum.any?(errors, fn
|
||||
%Ash.Error.Changes.InvalidAttribute{message: msg} ->
|
||||
String.contains?(msg, "already linked to a different OIDC account")
|
||||
|
||||
_ ->
|
||||
false
|
||||
end)
|
||||
|
||||
# Should NOT be PasswordVerificationRequired
|
||||
refute Enum.any?(errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
end
|
||||
|
||||
test "E2E: empty string oidc_id is treated as password-only account", %{conn: _conn} do
|
||||
# User with empty oidc_id
|
||||
_password_user =
|
||||
create_test_user(%{
|
||||
email: "empty@example.com",
|
||||
password: "pass123",
|
||||
oidc_id: ""
|
||||
})
|
||||
|
||||
# Try OIDC registration
|
||||
user_info = %{
|
||||
"sub" => "oidc_new_222",
|
||||
"preferred_username" => "empty@example.com"
|
||||
}
|
||||
|
||||
{:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
# Should require password (empty string = no OIDC)
|
||||
assert Enum.any?(errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "E2E: Error scenarios" do
|
||||
test "E2E: OIDC registration without oidc_id fails", %{conn: _conn} do
|
||||
user_info = %{
|
||||
"preferred_username" => "noid@example.com"
|
||||
}
|
||||
|
||||
{:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
assert Enum.any?(errors, fn err ->
|
||||
match?(%Ash.Error.Changes.InvalidChanges{}, err)
|
||||
end)
|
||||
end
|
||||
|
||||
test "E2E: OIDC registration without email fails", %{conn: _conn} do
|
||||
user_info = %{
|
||||
"sub" => "noemail_123"
|
||||
}
|
||||
|
||||
{:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
assert Enum.any?(errors, fn err ->
|
||||
match?(%Ash.Error.Changes.Required{field: :email}, err)
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
271
test/mv_web/controllers/oidc_email_update_test.exs
Normal file
271
test/mv_web/controllers/oidc_email_update_test.exs
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
defmodule MvWeb.OidcEmailUpdateTest do
|
||||
@moduledoc """
|
||||
Tests for OIDC email updates - when an existing OIDC user changes their email
|
||||
in the OIDC provider and logs in again.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
|
||||
describe "OIDC user updates email to available email" do
|
||||
test "should succeed and update email" do
|
||||
# Create OIDC user
|
||||
{:ok, oidc_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "original@example.com"
|
||||
})
|
||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_123")
|
||||
|> Ash.create()
|
||||
|
||||
# User logs in via OIDC with NEW email
|
||||
user_info = %{
|
||||
"sub" => "oidc_123",
|
||||
"preferred_username" => "newemail@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should succeed and email should be updated
|
||||
assert {:ok, updated_user} = result
|
||||
assert updated_user.id == oidc_user.id
|
||||
assert to_string(updated_user.email) == "newemail@example.com"
|
||||
assert updated_user.oidc_id == "oidc_123"
|
||||
end
|
||||
end
|
||||
|
||||
describe "OIDC user updates email to email of passwordless user" do
|
||||
test "should fail with clear error message" do
|
||||
# Create OIDC user
|
||||
{:ok, _oidc_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "oidcuser@example.com"
|
||||
})
|
||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_456")
|
||||
|> Ash.create()
|
||||
|
||||
# Create passwordless user with target email
|
||||
{:ok, _passwordless_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "taken@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# OIDC user tries to update email to taken email
|
||||
user_info = %{
|
||||
"sub" => "oidc_456",
|
||||
"preferred_username" => "taken@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should fail with email update conflict error
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||
|
||||
# Should contain error about email being registered to another account
|
||||
assert Enum.any?(errors, fn
|
||||
%Ash.Error.Changes.InvalidAttribute{field: :email, message: message} ->
|
||||
String.contains?(message, "Cannot update email to") and
|
||||
String.contains?(message, "already registered to another account")
|
||||
|
||||
_ ->
|
||||
false
|
||||
end)
|
||||
|
||||
# Should NOT contain PasswordVerificationRequired
|
||||
refute Enum.any?(errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "OIDC user updates email to email of password-protected user" do
|
||||
test "should fail with clear error message" do
|
||||
# Create OIDC user
|
||||
{:ok, _oidc_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "oidcuser2@example.com"
|
||||
})
|
||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_789")
|
||||
|> Ash.create()
|
||||
|
||||
# Create password user with target email (explicitly NO oidc_id)
|
||||
password_user =
|
||||
create_test_user(%{
|
||||
email: "passworduser@example.com",
|
||||
password: "securepass123"
|
||||
})
|
||||
|
||||
# Ensure it's a password-only user
|
||||
{:ok, password_user} = Ash.reload(password_user)
|
||||
assert not is_nil(password_user.hashed_password)
|
||||
# Force oidc_id to be nil to avoid any confusion
|
||||
{:ok, password_user} =
|
||||
password_user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.force_change_attribute(:oidc_id, nil)
|
||||
|> Ash.update()
|
||||
|
||||
assert is_nil(password_user.oidc_id)
|
||||
|
||||
# OIDC user tries to update email to password user's email
|
||||
user_info = %{
|
||||
"sub" => "oidc_789",
|
||||
"preferred_username" => "passworduser@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should fail with email update conflict error
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||
|
||||
# Should contain error about email being registered to another account
|
||||
assert Enum.any?(errors, fn
|
||||
%Ash.Error.Changes.InvalidAttribute{field: :email, message: message} ->
|
||||
String.contains?(message, "Cannot update email to") and
|
||||
String.contains?(message, "already registered to another account")
|
||||
|
||||
_ ->
|
||||
false
|
||||
end)
|
||||
|
||||
# Should NOT contain PasswordVerificationRequired
|
||||
refute Enum.any?(errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "OIDC user updates email to email of different OIDC user" do
|
||||
test "should fail with clear error message about different OIDC account" do
|
||||
# Create first OIDC user
|
||||
{:ok, _oidc_user1} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "oidcuser1@example.com"
|
||||
})
|
||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_aaa")
|
||||
|> Ash.create()
|
||||
|
||||
# Create second OIDC user with target email
|
||||
{:ok, _oidc_user2} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "oidcuser2@example.com"
|
||||
})
|
||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_bbb")
|
||||
|> Ash.create()
|
||||
|
||||
# First OIDC user tries to update email to second user's email
|
||||
user_info = %{
|
||||
"sub" => "oidc_aaa",
|
||||
"preferred_username" => "oidcuser2@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should fail with "already linked to different OIDC account" error
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||
|
||||
# Should contain error about different OIDC account
|
||||
assert Enum.any?(errors, fn
|
||||
%Ash.Error.Changes.InvalidAttribute{field: :email, message: message} ->
|
||||
String.contains?(message, "already linked to a different OIDC account")
|
||||
|
||||
_ ->
|
||||
false
|
||||
end)
|
||||
|
||||
# Should NOT contain PasswordVerificationRequired
|
||||
refute Enum.any?(errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "New OIDC user registration scenarios (for comparison)" do
|
||||
test "new OIDC user with email of passwordless user triggers linking flow" do
|
||||
# Create passwordless user
|
||||
{:ok, passwordless_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "passwordless@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# New OIDC user tries to register
|
||||
user_info = %{
|
||||
"sub" => "new_oidc_999",
|
||||
"preferred_username" => "passwordless@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should trigger PasswordVerificationRequired (linking flow)
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||
|
||||
assert Enum.any?(errors, fn
|
||||
%Mv.Accounts.User.Errors.PasswordVerificationRequired{user_id: user_id} ->
|
||||
user_id == passwordless_user.id
|
||||
|
||||
_ ->
|
||||
false
|
||||
end)
|
||||
end
|
||||
|
||||
test "new OIDC user with email of existing OIDC user shows hard error" do
|
||||
# Create existing OIDC user
|
||||
{:ok, _existing_oidc_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "existing@example.com"
|
||||
})
|
||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "oidc_existing")
|
||||
|> Ash.create()
|
||||
|
||||
# New OIDC user tries to register with same email
|
||||
user_info = %{
|
||||
"sub" => "oidc_new",
|
||||
"preferred_username" => "existing@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should fail with "already linked to different OIDC account" error
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||
|
||||
assert Enum.any?(errors, fn
|
||||
%Ash.Error.Changes.InvalidAttribute{field: :email, message: message} ->
|
||||
String.contains?(message, "already linked to a different OIDC account")
|
||||
|
||||
_ ->
|
||||
false
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -54,8 +54,128 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "OIDC sign-in security tests" do
|
||||
@tag :test_proposal
|
||||
test "sign_in_with_rauthy does NOT match user with only email (no oidc_id)" do
|
||||
# SECURITY TEST: Ensure password-only users cannot be accessed via OIDC
|
||||
# Create a password-only user (no oidc_id)
|
||||
_password_user =
|
||||
create_test_user(%{
|
||||
email: "password.only@example.com",
|
||||
password: "securepassword123",
|
||||
oidc_id: nil
|
||||
})
|
||||
|
||||
# Try to sign in with OIDC using the same email
|
||||
user_info = %{
|
||||
"sub" => "attacker_oidc_456",
|
||||
"preferred_username" => "password.only@example.com"
|
||||
}
|
||||
|
||||
# Should NOT find any user (security requirement)
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
# Either returns empty list OR authentication error - both mean "user not found"
|
||||
case result do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||
:ok
|
||||
|
||||
other ->
|
||||
flunk("Expected no user match, got: #{inspect(other)}")
|
||||
end
|
||||
end
|
||||
|
||||
@tag :test_proposal
|
||||
test "sign_in_with_rauthy only matches when oidc_id matches" do
|
||||
# Create user with specific OIDC ID
|
||||
user =
|
||||
create_test_user(%{
|
||||
email: "oidc.user@example.com",
|
||||
oidc_id: "correct_oidc_789"
|
||||
})
|
||||
|
||||
# Try with correct oidc_id
|
||||
correct_user_info = %{
|
||||
"sub" => "correct_oidc_789",
|
||||
"preferred_username" => "oidc.user@example.com"
|
||||
}
|
||||
|
||||
{:ok, [found_user]} =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
||||
user_info: correct_user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
assert found_user.id == user.id
|
||||
|
||||
# Try with wrong oidc_id but correct email
|
||||
wrong_user_info = %{
|
||||
"sub" => "wrong_oidc_999",
|
||||
"preferred_username" => "oidc.user@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
||||
user_info: wrong_user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
# Either returns empty list OR authentication error - both mean "user not found"
|
||||
case result do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||
:ok
|
||||
|
||||
other ->
|
||||
flunk("Expected no user match when oidc_id differs, got: #{inspect(other)}")
|
||||
end
|
||||
end
|
||||
|
||||
@tag :test_proposal
|
||||
test "sign_in_with_rauthy does not match user with empty string oidc_id" do
|
||||
# Edge case: empty string should be treated like nil
|
||||
_user =
|
||||
create_test_user(%{
|
||||
email: "empty.oidc@example.com",
|
||||
oidc_id: ""
|
||||
})
|
||||
|
||||
user_info = %{
|
||||
"sub" => "new_oidc_111",
|
||||
"preferred_username" => "empty.oidc@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
# Either returns empty list OR authentication error - both mean "user not found"
|
||||
case result do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||
:ok
|
||||
|
||||
other ->
|
||||
flunk("Expected no user match with empty oidc_id, got: #{inspect(other)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "OIDC error and edge case scenarios" do
|
||||
test "OIDC registration with conflicting email and OIDC ID shows error" do
|
||||
test "OIDC registration with conflicting email and OIDC ID shows hard error" do
|
||||
# Create user with email and OIDC ID
|
||||
_existing_user =
|
||||
create_test_user(%{
|
||||
|
|
@ -75,16 +195,24 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
# Should fail due to unique constraint
|
||||
# Should fail with hard error (not PasswordVerificationRequired)
|
||||
# This prevents someone with OIDC provider B from taking over an account
|
||||
# that's already linked to OIDC provider A
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||
|
||||
# Should contain error about "already linked to a different OIDC account"
|
||||
assert Enum.any?(errors, fn
|
||||
%Ash.Error.Changes.InvalidAttribute{field: :email, message: message} ->
|
||||
String.contains?(message, "has already been taken")
|
||||
%Ash.Error.Changes.InvalidAttribute{message: msg} ->
|
||||
String.contains?(msg, "already linked to a different OIDC account")
|
||||
|
||||
_ ->
|
||||
false
|
||||
end)
|
||||
|
||||
# Should NOT be PasswordVerificationRequired
|
||||
refute Enum.any?(errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
end
|
||||
|
||||
test "OIDC registration with missing sub and id should fail" do
|
||||
|
|
|
|||
496
test/mv_web/controllers/oidc_password_linking_test.exs
Normal file
496
test/mv_web/controllers/oidc_password_linking_test.exs
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
defmodule MvWeb.OidcPasswordLinkingTest do
|
||||
@moduledoc """
|
||||
Tests for OIDC account linking when email collision occurs.
|
||||
|
||||
This test suite verifies the security flow when an OIDC login attempts
|
||||
to use an email that already exists in the system with a password account.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
require Ash.Query
|
||||
|
||||
describe "OIDC login with existing email (no oidc_id) - Email Collision" do
|
||||
@tag :test_proposal
|
||||
test "OIDC register with existing password user email fails with PasswordVerificationRequired" do
|
||||
# Create password-only user
|
||||
existing_user =
|
||||
create_test_user(%{
|
||||
email: "existing@example.com",
|
||||
password: "securepassword123",
|
||||
oidc_id: nil
|
||||
})
|
||||
|
||||
# Try OIDC registration with same email
|
||||
user_info = %{
|
||||
"sub" => "new_oidc_12345",
|
||||
"preferred_username" => "existing@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
# Should fail with PasswordVerificationRequired error
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||
|
||||
# Check that the error is our custom PasswordVerificationRequired
|
||||
password_verification_error =
|
||||
Enum.find(errors, fn err ->
|
||||
err.__struct__ == Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||
end)
|
||||
|
||||
assert password_verification_error != nil,
|
||||
"Should contain PasswordVerificationRequired error"
|
||||
|
||||
assert password_verification_error.user_id == existing_user.id
|
||||
end
|
||||
|
||||
@tag :test_proposal
|
||||
test "PasswordVerificationRequired error contains necessary context" do
|
||||
existing_user =
|
||||
create_test_user(%{
|
||||
email: "test@example.com",
|
||||
password: "password123",
|
||||
oidc_id: nil
|
||||
})
|
||||
|
||||
user_info = %{
|
||||
"sub" => "oidc_99999",
|
||||
"preferred_username" => "test@example.com"
|
||||
}
|
||||
|
||||
{:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
password_error =
|
||||
Enum.find(errors, fn err ->
|
||||
err.__struct__ == Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||
end)
|
||||
|
||||
# Verify error contains all necessary context
|
||||
assert password_error.user_id == existing_user.id
|
||||
assert password_error.oidc_user_info["sub"] == "oidc_99999"
|
||||
assert password_error.oidc_user_info["preferred_username"] == "test@example.com"
|
||||
end
|
||||
|
||||
@tag :test_proposal
|
||||
test "after successful password verification, oidc_id can be set" do
|
||||
# Create password user
|
||||
user =
|
||||
create_test_user(%{
|
||||
email: "link@example.com",
|
||||
password: "mypassword123",
|
||||
oidc_id: nil
|
||||
})
|
||||
|
||||
# Simulate password verification passed, now link OIDC
|
||||
user_info = %{
|
||||
"sub" => "linked_oidc_555",
|
||||
"preferred_username" => "link@example.com"
|
||||
}
|
||||
|
||||
# Use the link_oidc_id action
|
||||
{:ok, updated_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(id == ^user.id)
|
||||
|> Ash.read_one!()
|
||||
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
||||
oidc_id: user_info["sub"],
|
||||
oidc_user_info: user_info
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
assert updated_user.id == user.id
|
||||
assert updated_user.oidc_id == "linked_oidc_555"
|
||||
assert to_string(updated_user.email) == "link@example.com"
|
||||
# Password should still exist
|
||||
assert updated_user.hashed_password == user.hashed_password
|
||||
end
|
||||
|
||||
@tag :test_proposal
|
||||
test "password verification with wrong password keeps oidc_id as nil" do
|
||||
# This test verifies that if password verification fails,
|
||||
# the oidc_id should NOT be set
|
||||
|
||||
user =
|
||||
create_test_user(%{
|
||||
email: "secure@example.com",
|
||||
password: "correctpassword",
|
||||
oidc_id: nil
|
||||
})
|
||||
|
||||
# This test verifies the CONCEPT that wrong password should prevent linking
|
||||
# In practice, the password verification happens BEFORE calling link_oidc_id
|
||||
# So we just verify that the user still has no oidc_id
|
||||
|
||||
# Attempt to verify with wrong password would fail in the controller/LiveView
|
||||
# before link_oidc_id is called, so here we just verify the user state
|
||||
|
||||
# User should still have no oidc_id (no linking happened)
|
||||
{:ok, unchanged_user} = Ash.get(Mv.Accounts.User, user.id)
|
||||
assert is_nil(unchanged_user.oidc_id)
|
||||
assert unchanged_user.hashed_password == user.hashed_password
|
||||
end
|
||||
end
|
||||
|
||||
describe "OIDC login with email of user having different oidc_id - Account Conflict" do
|
||||
@tag :test_proposal
|
||||
test "OIDC register with email of user having different oidc_id fails" do
|
||||
# User already linked to OIDC provider A
|
||||
_existing_user =
|
||||
create_test_user(%{
|
||||
email: "linked@example.com",
|
||||
oidc_id: "oidc_provider_a_123"
|
||||
})
|
||||
|
||||
# Someone tries to register with OIDC provider B using same email
|
||||
user_info = %{
|
||||
# Different OIDC ID!
|
||||
"sub" => "oidc_provider_b_456",
|
||||
"preferred_username" => "linked@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
# Should fail - cannot link different OIDC account to same email
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||
|
||||
# The error should indicate email is already taken
|
||||
assert Enum.any?(errors, fn err ->
|
||||
(err.__struct__ == Ash.Error.Changes.InvalidAttribute and err.field == :email) or
|
||||
err.__struct__ == Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||
end)
|
||||
end
|
||||
|
||||
@tag :test_proposal
|
||||
test "existing OIDC user email remains unchanged when oidc_id matches" do
|
||||
user =
|
||||
create_test_user(%{
|
||||
email: "oidc@example.com",
|
||||
oidc_id: "oidc_stable_789"
|
||||
})
|
||||
|
||||
# Same OIDC ID, same email - should just sign in
|
||||
user_info = %{
|
||||
"sub" => "oidc_stable_789",
|
||||
"preferred_username" => "oidc@example.com"
|
||||
}
|
||||
|
||||
# This should work via upsert
|
||||
{:ok, updated_user} =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
assert updated_user.id == user.id
|
||||
assert updated_user.oidc_id == "oidc_stable_789"
|
||||
assert to_string(updated_user.email) == "oidc@example.com"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Email update during OIDC linking" do
|
||||
@tag :test_proposal
|
||||
test "linking OIDC to password account updates email if different in OIDC" do
|
||||
# Password user with old email
|
||||
user =
|
||||
create_test_user(%{
|
||||
email: "oldemail@example.com",
|
||||
password: "password123",
|
||||
oidc_id: nil
|
||||
})
|
||||
|
||||
# OIDC provider returns new email (user changed it there)
|
||||
user_info = %{
|
||||
"sub" => "oidc_link_999",
|
||||
"preferred_username" => "newemail@example.com"
|
||||
}
|
||||
|
||||
# After password verification, link and update email
|
||||
{:ok, updated_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(id == ^user.id)
|
||||
|> Ash.read_one!()
|
||||
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
||||
oidc_id: user_info["sub"],
|
||||
oidc_user_info: user_info
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
assert updated_user.oidc_id == "oidc_link_999"
|
||||
assert to_string(updated_user.email) == "newemail@example.com"
|
||||
end
|
||||
|
||||
@tag :test_proposal
|
||||
test "email change during linking triggers member email sync" do
|
||||
# Create member
|
||||
member =
|
||||
Ash.Seed.seed!(Mv.Membership.Member, %{
|
||||
email: "member@example.com",
|
||||
first_name: "Test",
|
||||
last_name: "User"
|
||||
})
|
||||
|
||||
# Create user linked to member
|
||||
user =
|
||||
Ash.Seed.seed!(Mv.Accounts.User, %{
|
||||
email: "member@example.com",
|
||||
hashed_password: "dummy_hash",
|
||||
oidc_id: nil,
|
||||
member_id: member.id
|
||||
})
|
||||
|
||||
# Link OIDC with new email
|
||||
user_info = %{
|
||||
"sub" => "oidc_sync_777",
|
||||
"preferred_username" => "newemail@example.com"
|
||||
}
|
||||
|
||||
{:ok, updated_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.filter(id == ^user.id)
|
||||
|> Ash.read_one!()
|
||||
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
||||
oidc_id: user_info["sub"],
|
||||
oidc_user_info: user_info
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
# Verify user email changed
|
||||
assert to_string(updated_user.email) == "newemail@example.com"
|
||||
|
||||
# Verify member email was synced
|
||||
{:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id)
|
||||
assert to_string(updated_member.email) == "newemail@example.com"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Edge cases" do
|
||||
@tag :test_proposal
|
||||
test "user with empty string oidc_id is treated as password-only user" do
|
||||
_user =
|
||||
create_test_user(%{
|
||||
email: "empty@example.com",
|
||||
password: "password123",
|
||||
oidc_id: ""
|
||||
})
|
||||
|
||||
# Try OIDC registration with same email
|
||||
user_info = %{
|
||||
"sub" => "oidc_new_111",
|
||||
"preferred_username" => "empty@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{}
|
||||
})
|
||||
|
||||
# Should trigger PasswordVerificationRequired (empty string = no OIDC)
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = result
|
||||
|
||||
password_error =
|
||||
Enum.find(errors, fn err ->
|
||||
err.__struct__ == Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||
end)
|
||||
|
||||
assert password_error != nil
|
||||
end
|
||||
|
||||
@tag :test_proposal
|
||||
test "cannot link same oidc_id to multiple users" do
|
||||
# User 1 with OIDC
|
||||
_user1 =
|
||||
create_test_user(%{
|
||||
email: "user1@example.com",
|
||||
oidc_id: "shared_oidc_333"
|
||||
})
|
||||
|
||||
# Try to create user 2 with same OIDC ID using raw Ash.Changeset
|
||||
# (create_test_user uses Ash.Seed which does upsert)
|
||||
result =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "user2@example.com"
|
||||
})
|
||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "shared_oidc_333")
|
||||
|> Ash.create()
|
||||
|
||||
# Should fail due to unique constraint on oidc_id
|
||||
assert match?({:error, %Ash.Error.Invalid{}}, result)
|
||||
|
||||
{:error, error} = result
|
||||
# Verify the error is about oidc_id uniqueness
|
||||
assert Enum.any?(error.errors, fn err ->
|
||||
match?(%Ash.Error.Changes.InvalidAttribute{field: :oidc_id}, err)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "OIDC login with passwordless user - Requires Linking Flow" do
|
||||
test "user without password and without oidc_id triggers PasswordVerificationRequired" do
|
||||
# Create user without password (e.g., invited user)
|
||||
{:ok, existing_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "invited@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Verify user has no password and no oidc_id
|
||||
assert is_nil(existing_user.hashed_password)
|
||||
assert is_nil(existing_user.oidc_id)
|
||||
|
||||
# OIDC registration should trigger linking flow (not automatic)
|
||||
user_info = %{
|
||||
"sub" => "auto_link_oidc_123",
|
||||
"preferred_username" => "invited@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should fail with PasswordVerificationRequired
|
||||
# The LinkOidcAccountLive will auto-link without password prompt
|
||||
assert {:error, %Ash.Error.Invalid{}} = result
|
||||
{:error, error} = result
|
||||
|
||||
assert Enum.any?(error.errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
end
|
||||
|
||||
test "user without password but WITH password later requires verification" do
|
||||
# Create user without password first
|
||||
{:ok, user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "added-password@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# User sets password later (using admin action)
|
||||
{:ok, user_with_password} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:admin_set_password, %{
|
||||
password: "newpassword123"
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
assert not is_nil(user_with_password.hashed_password)
|
||||
|
||||
# Now OIDC login should require password verification
|
||||
user_info = %{
|
||||
"sub" => "needs_verification",
|
||||
"preferred_username" => "added-password@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should fail with PasswordVerificationRequired
|
||||
assert {:error, %Ash.Error.Invalid{}} = result
|
||||
{:error, error} = result
|
||||
|
||||
assert Enum.any?(error.errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "OIDC login with different oidc_id - Hard Error" do
|
||||
test "user with different oidc_id cannot be linked (hard error)" do
|
||||
# Create user with existing OIDC ID
|
||||
{:ok, existing_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "already-linked@example.com"
|
||||
})
|
||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "original_oidc_999")
|
||||
|> Ash.create()
|
||||
|
||||
assert existing_user.oidc_id == "original_oidc_999"
|
||||
|
||||
# Try to register with same email but different OIDC ID
|
||||
user_info = %{
|
||||
"sub" => "different_oidc_888",
|
||||
"preferred_username" => "already-linked@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should fail with hard error (not PasswordVerificationRequired)
|
||||
assert {:error, %Ash.Error.Invalid{}} = result
|
||||
{:error, error} = result
|
||||
|
||||
# Should NOT be PasswordVerificationRequired
|
||||
refute Enum.any?(error.errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
|
||||
# Should be a validation error about email already linked
|
||||
assert Enum.any?(error.errors, fn err ->
|
||||
case err do
|
||||
%Ash.Error.Changes.InvalidAttribute{message: msg} ->
|
||||
String.contains?(msg, "already linked to a different OIDC account")
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
test "cannot link different oidc_id even with password verification" do
|
||||
# Create user with password AND existing OIDC ID
|
||||
existing_user =
|
||||
create_test_user(%{
|
||||
email: "password-and-oidc@example.com",
|
||||
password: "mypassword123",
|
||||
oidc_id: "first_oidc_111"
|
||||
})
|
||||
|
||||
assert existing_user.oidc_id == "first_oidc_111"
|
||||
assert not is_nil(existing_user.hashed_password)
|
||||
|
||||
# Try to register with different OIDC ID
|
||||
user_info = %{
|
||||
"sub" => "second_oidc_222",
|
||||
"preferred_username" => "password-and-oidc@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should fail - cannot link different OIDC ID
|
||||
assert {:error, %Ash.Error.Invalid{}} = result
|
||||
{:error, error} = result
|
||||
|
||||
# Should be a hard error, not password verification
|
||||
refute Enum.any?(error.errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
210
test/mv_web/controllers/oidc_passwordless_linking_test.exs
Normal file
210
test/mv_web/controllers/oidc_passwordless_linking_test.exs
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
defmodule MvWeb.OidcPasswordlessLinkingTest do
|
||||
@moduledoc """
|
||||
Tests for OIDC account linking with passwordless users.
|
||||
|
||||
These tests verify the behavior when a passwordless user
|
||||
(e.g., invited user, user created by admin) attempts to log in via OIDC.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
|
||||
describe "Passwordless user - Automatic linking via special action" do
|
||||
test "passwordless user can be linked via link_passwordless_oidc action" do
|
||||
# Create user without password (e.g., invited user)
|
||||
{:ok, existing_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "invited@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Verify user has no password and no oidc_id
|
||||
assert is_nil(existing_user.hashed_password)
|
||||
assert is_nil(existing_user.oidc_id)
|
||||
|
||||
# Link via special action (simulating what happens after first OIDC attempt)
|
||||
{:ok, linked_user} =
|
||||
existing_user
|
||||
|> Ash.Changeset.for_update(:link_oidc_id, %{
|
||||
oidc_id: "auto_link_oidc_123",
|
||||
oidc_user_info: %{
|
||||
"sub" => "auto_link_oidc_123",
|
||||
"preferred_username" => "invited@example.com"
|
||||
}
|
||||
})
|
||||
|> Ash.update()
|
||||
|
||||
# User should now have oidc_id linked
|
||||
assert linked_user.oidc_id == "auto_link_oidc_123"
|
||||
assert linked_user.id == existing_user.id
|
||||
|
||||
# Now OIDC sign-in should work
|
||||
result =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Query.for_read(:sign_in_with_rauthy, %{
|
||||
user_info: %{
|
||||
"sub" => "auto_link_oidc_123",
|
||||
"preferred_username" => "invited@example.com"
|
||||
},
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|> Ash.read_one()
|
||||
|
||||
assert {:ok, signed_in_user} = result
|
||||
assert signed_in_user.id == existing_user.id
|
||||
end
|
||||
|
||||
test "passwordless user triggers PasswordVerificationRequired for linking flow" do
|
||||
# Create passwordless user
|
||||
{:ok, existing_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "passwordless@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert is_nil(existing_user.hashed_password)
|
||||
assert is_nil(existing_user.oidc_id)
|
||||
|
||||
# Try OIDC registration - should trigger PasswordVerificationRequired
|
||||
user_info = %{
|
||||
"sub" => "new_oidc_456",
|
||||
"preferred_username" => "passwordless@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should fail with PasswordVerificationRequired
|
||||
# LinkOidcAccountLive will auto-link without password prompt
|
||||
assert {:error, %Ash.Error.Invalid{}} = result
|
||||
{:error, error} = result
|
||||
|
||||
assert Enum.any?(error.errors, fn err ->
|
||||
case err do
|
||||
%Mv.Accounts.User.Errors.PasswordVerificationRequired{user_id: user_id} ->
|
||||
user_id == existing_user.id
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "User with different OIDC ID - Hard Error" do
|
||||
test "user with different oidc_id gets hard error, not password verification" do
|
||||
# Create user with existing OIDC ID
|
||||
{:ok, _existing_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "already-linked@example.com"
|
||||
})
|
||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "original_oidc_999")
|
||||
|> Ash.create()
|
||||
|
||||
# Try to register with same email but different OIDC ID
|
||||
user_info = %{
|
||||
"sub" => "different_oidc_888",
|
||||
"preferred_username" => "already-linked@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should fail with hard error
|
||||
assert {:error, %Ash.Error.Invalid{}} = result
|
||||
{:error, error} = result
|
||||
|
||||
# Should NOT be PasswordVerificationRequired
|
||||
refute Enum.any?(error.errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
|
||||
# Should have error message about already linked
|
||||
assert Enum.any?(error.errors, fn err ->
|
||||
case err do
|
||||
%Ash.Error.Changes.InvalidAttribute{message: msg} ->
|
||||
String.contains?(msg, "already linked to a different OIDC account")
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
test "passwordless user with different oidc_id also gets hard error" do
|
||||
# Create passwordless user with OIDC ID
|
||||
{:ok, existing_user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "passwordless-linked@example.com"
|
||||
})
|
||||
|> Ash.Changeset.force_change_attribute(:oidc_id, "first_oidc_777")
|
||||
|> Ash.create()
|
||||
|
||||
assert is_nil(existing_user.hashed_password)
|
||||
assert existing_user.oidc_id == "first_oidc_777"
|
||||
|
||||
# Try to register with different OIDC ID
|
||||
user_info = %{
|
||||
"sub" => "second_oidc_666",
|
||||
"preferred_username" => "passwordless-linked@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should be hard error, not PasswordVerificationRequired
|
||||
assert {:error, %Ash.Error.Invalid{}} = result
|
||||
{:error, error} = result
|
||||
|
||||
refute Enum.any?(error.errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "Password user - Requires verification (existing behavior)" do
|
||||
test "password user without oidc_id requires password verification" do
|
||||
# Create password user
|
||||
password_user =
|
||||
create_test_user(%{
|
||||
email: "password@example.com",
|
||||
password: "securepass123",
|
||||
oidc_id: nil
|
||||
})
|
||||
|
||||
assert not is_nil(password_user.hashed_password)
|
||||
assert is_nil(password_user.oidc_id)
|
||||
|
||||
# Try OIDC registration
|
||||
user_info = %{
|
||||
"sub" => "new_oidc_999",
|
||||
"preferred_username" => "password@example.com"
|
||||
}
|
||||
|
||||
result =
|
||||
Mv.Accounts.create_register_with_rauthy(%{
|
||||
user_info: user_info,
|
||||
oauth_tokens: %{"access_token" => "test_token"}
|
||||
})
|
||||
|
||||
# Should require password verification
|
||||
assert {:error, %Ash.Error.Invalid{}} = result
|
||||
{:error, error} = result
|
||||
|
||||
assert Enum.any?(error.errors, fn err ->
|
||||
match?(%Mv.Accounts.User.Errors.PasswordVerificationRequired{}, err)
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue