From cc4953da50fd94c602fad270b2361e15252cfeec Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 13 Jan 2026 20:01:11 +0100 Subject: [PATCH] update docs --- ...ementation_doc_userauth_process_concept.md | 1450 +++++++++++++++++ ...lementation_doc_userauth_ui_adaptations.md | 400 +++++ .../connector_generic_refactoring_analysis.md | 265 +++ .../doc_trustee_feature_ui_specification.md | 97 +- 4 files changed, 2181 insertions(+), 31 deletions(-) create mode 100644 implementation/implementation_doc_userauth_process_concept.md create mode 100644 implementation/implementation_doc_userauth_ui_adaptations.md create mode 100644 reviews/connector_generic_refactoring_analysis.md diff --git a/implementation/implementation_doc_userauth_process_concept.md b/implementation/implementation_doc_userauth_process_concept.md new file mode 100644 index 0000000..29a3432 --- /dev/null +++ b/implementation/implementation_doc_userauth_process_concept.md @@ -0,0 +1,1450 @@ +# User Authentication Process - Concept Document + +## Overview + +This document defines the architecture and data objects for the refactored user authentication process in PowerOn, including self-registration with magic links, password reset functionality, and the associated data model changes. + +**Last Updated**: 2025-01-12 - Deep code review completed, ready for implementation. + +## Executive Summary + +This document has been thoroughly reviewed against the actual codebase in `gateway` and `frontend_agents`. The analysis confirms that **most features are not yet implemented** but the infrastructure exists. + +### 🚨 CRITICAL BUG DISCOVERED + +**`resetUserPassword()` method is called but NOT implemented!** +- Called in `routeDataUsers.py` (lines 218, 294) +- Does NOT exist in `interfaceDbAppObjects.py` +- This will cause a runtime error if admin tries to reset a password +- **MUST be implemented as part of this work** + +### Critical Gaps (Ordered by Implementation Priority): + +| Priority | Gap | Effort | Location | +|----------|-----|--------|----------| +| 🔴 P0 | `resetUserPassword()` method missing | Small | `interfaceDbAppObjects.py` | +| 🔴 P1 | `sendEmailDirect()` method in messaging | Small | `mainServiceMessaging.py` | +| 🔴 P1 | `resetToken` + `resetTokenExpires` fields | Small | `datamodelUam.py` | +| 🔴 P1 | Password reset endpoints | Medium | `routeSecurityLocal.py` | +| 🟡 P2 | Remove password from registration | Medium | `routeSecurityLocal.py` | +| 🟡 P2 | Frontend password reset pages | Medium | `frontend_agents/public/` | +| 🟡 P2 | Frontend API calls for reset | Small | `apiCalls.js` | +| 🟢 P3 | Config entries (`Auth_RESET_TOKEN_EXPIRY_HOURS`, `Frontend_BASE_URL`) | Small | `config.ini` | + +### What Already Exists (✅ Verified): +- ✅ **Email sending infrastructure** - `serviceMessaging` at `self.services.messaging` (line 96 in `services/__init__.py`) +- ✅ **Email connector** - `ConnectorMessagingEmail` using Azure Communication Services +- ✅ **interfaceMessaging.send()** - Unified interface for email/SMS (line 31 in `interfaceMessaging.py`) +- ✅ **Password hashing** - `_getPasswordHash()` / `_verifyPassword()` (lines 442-448 in `interfaceDbAppObjects.py`) +- ✅ **User data models** - `User`, `UserInDB` (lines 131, 166 in `datamodelUam.py`) +- ✅ **Admin password reset route** - `/api/users/{userId}/reset-password` (line 200 in `routeDataUsers.py`) +- ✅ **Frontend pages** - `login.html`, `register.html` (need modifications) +- ✅ **Timestamp utilities** - `getUtcTimestamp()`, `createExpirationTimestamp()` (in `timeUtils.py`) + +### Implementation Estimate: +- **Backend**: ~4-6 hours +- **Frontend**: ~3-4 hours +- **Testing**: ~2-3 hours +- **Total**: ~10-13 hours + +See `doc_userauth_ui_adaptations.md` for detailed UI implementation requirements. + +## Current State Analysis + +### Existing Authentication Flow (Verified) + +| Component | File | Status | Notes | +|-----------|------|--------|-------| +| Login Page | `frontend_agents/public/login.html` | ✅ Works | Missing password reset link | +| Microsoft Auth | `routeSecurityMsft.py` | ✅ Works | - | +| Google Auth | `routeSecurityGoogle.py` | ✅ Works | - | +| Local Auth | `routeSecurityLocal.py` | ✅ Works | - | +| Registration | `register.html` | ⚠️ Needs change | Requires password | + +### Current User Data Model (`datamodelUam.py`) + +```python +# Line 166-172 +class UserInDB(User): + hashedPassword: Optional[str] = Field(None, description="Hash of the user password") +# ❌ MISSING: resetToken, resetTokenExpires fields +``` + +**Reference**: `UserConnection` model (line 95-111) uses `float` timestamps for `connectedAt`, `lastChecked`, `expiresAt` - same pattern to use for `resetTokenExpires`. + +### Current Authentication Logic (`interfaceDbAppObjects.py`) + +```python +# Lines 570-596 - authenticateLocalUser() +def authenticateLocalUser(self, username: str, password: str) -> Optional[User]: + # ... gets user ... + + # Line 590: Check for password hash + if not userRecord.get("hashedPassword"): + raise ValueError("User has no password set") + # ❌ MISSING: Check for resetToken (should block login if set) + + if not self._verifyPassword(password, userRecord["hashedPassword"]): + raise ValueError("Invalid password") +``` + +### Current Registration Logic (`routeSecurityLocal.py`) + +```python +# Line 192-248 +@router.post("/register", response_model=User) +async def register_user( + request: Request, + userData: User = Body(...), + password: str = Body(..., embed=True) # ❌ Line 197: Password required - needs removal +) -> User: + # ... + user = appInterface.createUser( + # ... + enabled=False, # ❌ Line 225: Should be True + # ... + ) +``` + +### Current createUser Logic (`interfaceDbAppObjects.py`) + +```python +# Lines 598-674 +def createUser( + self, + username: str, + password: str = None, # Already optional! + # ... +) -> User: + # Line 618-624: Password required for LOCAL auth - needs modification + if authenticationAuthority == AuthAuthority.LOCAL: + if not password: + raise ValueError("Password is required for local authentication") +``` + +### 🚨 Critical Bug: Missing `resetUserPassword()` Method + +**File**: `routeDataUsers.py` calls this method but it doesn't exist! + +```python +# Lines 218 and 294 in routeDataUsers.py: +success = appInterface.resetUserPassword(userId, newPassword) +# ❌ This method does NOT exist in interfaceDbAppObjects.py! +``` + +**Required implementation**: +```python +def resetUserPassword(self, userId: str, newPassword: str) -> bool: + """Reset a user's password (admin function).""" + try: + hashedPassword = self._getPasswordHash(newPassword) + self.db.recordModify(UserInDB, userId, {"hashedPassword": hashedPassword}) + return True + except Exception as e: + logger.error(f"Error resetting password for user {userId}: {str(e)}") + return False +``` + +### Frontend State Summary + +| File | Status | Required Changes | +|------|--------|------------------| +| `login.html` | ⚠️ | Add password reset link | +| `register.html` | ⚠️ | Remove password fields (lines 24-31) | +| `auth.js` | ⚠️ | Update `validateRegistrationForm()` (line 402) | +| `apiCalls.js` | ⚠️ | Update `register()` (line 343), add reset API calls | +| `password-reset-request.html` | ❌ | Create new | +| `reset.html` | ❌ | Create new | + +### Missing Components Checklist + +**Backend (Priority Order)**: +- [ ] `resetUserPassword()` in `interfaceDbAppObjects.py` - **CRITICAL BUG FIX** +- [ ] `resetToken` + `resetTokenExpires` fields in `UserInDB` +- [ ] `sendEmailDirect()` in `mainServiceMessaging.py` +- [ ] `findUserByEmailLocalAuth()` in `interfaceDbAppObjects.py` +- [ ] `generateResetTokenAndExpiry()` helper +- [ ] `verifyResetToken()` helper +- [ ] `resetPasswordWithToken()` helper +- [ ] `POST /api/local/password-reset-request` endpoint +- [ ] `POST /api/local/password-reset` endpoint +- [ ] `Auth_RESET_TOKEN_EXPIRY_HOURS` in `config.ini` +- [ ] `Frontend_BASE_URL` in `config.ini` + +**Frontend**: +- [ ] Password reset link in `login.html` +- [ ] Remove password fields from `register.html` +- [ ] Create `password-reset-request.html` +- [ ] Create `reset.html` +- [ ] Create `passwordResetRequest.js` +- [ ] Create `reset.js` +- [ ] Add `requestPasswordReset()` to `apiCalls.js` +- [ ] Add `resetPassword()` to `apiCalls.js` +- [ ] Update `register()` in `apiCalls.js` +- [ ] Update `validateRegistrationForm()` in `auth.js` + +## Required Changes + +### 1. Email Sending Service Integration + +#### Overview +Email sending infrastructure exists and works. The messaging service uses Azure Communication Services (ACS). + +#### Current Architecture (Verified) + +``` +services/__init__.py (line 96) + └── self.messaging = PublicService(MessagingService(self)) + └── mainServiceMessaging.py + └── _getMessagingInterface() → interfaceMessaging.py + └── MessagingInterface.send() + └── ConnectorMessagingEmail (Azure ACS) +``` + +#### Existing sendMessage() - Subscription-Based (Lines 41-131) + +```python +def sendMessage( + self, + subject: str, + message: str, + registration: MessagingSubscriptionRegistration # ❌ Requires subscription object +) -> MessagingSendResult: +``` + +#### Required: Add sendEmailDirect() Method + +**File**: `gateway/modules/services/serviceMessaging/mainServiceMessaging.py` +**Insert after line 131** (after `sendMessage` method): + +```python +def sendEmailDirect( + self, + recipient: str, + subject: str, + message: str, + userId: Optional[str] = None +) -> bool: + """ + Send email directly without requiring a subscription. + Used for authentication flows (registration, password reset). + + Args: + recipient: Email address of the recipient + subject: Email subject + message: Email body (can be HTML or plain text) + userId: Optional user ID for logging/audit purposes + + Returns: + bool: True if email was sent successfully, False otherwise + """ + try: + messagingInterface = self._getMessagingInterface() + success = messagingInterface.send( + channel=MessagingChannel.EMAIL, + recipient=recipient, + subject=subject, + message=message + ) + + if success: + logger.info(f"Email sent successfully to {recipient} (userId: {userId})") + else: + logger.warning(f"Failed to send email to {recipient} (userId: {userId})") + + return success + except Exception as e: + logger.error(f"Error sending email to {recipient}: {str(e)}", exc_info=True) + return False +``` + +#### Email Connector Configuration (Already Exists) + +**File**: `connectorMessagingEmail.py` - Uses these config vars: +```python +connectionString = APP_CONFIG.get("MESSAGING_ACS_CONNECTION_STRING") # Line 27 +senderEmail = APP_CONFIG.get("MESSAGING_ACS_SENDER_EMAIL") # Line 28 +``` + +These should already be set in `env_*.env` files for the messaging service to work. + +#### Usage in Authentication Flows + +**In Registration Endpoint** (`gateway/modules/routes/routeSecurityLocal.py`): +```python +# After creating user and generating reset token +from modules.datamodels.datamodelMessaging import MessagingChannel + +# Get services instance (requires user context) +# For registration, we need root interface user +rootInterface = getRootInterface() +from modules.datamodels.datamodelUam import Mandate +defaultMandateId = rootInterface.getInitialId(Mandate) +rootInterface.mandateId = defaultMandateId + +# Get root user for services context +rootUser = rootInterface.getUserById(rootInterface.getInitialId(UserInDB)) +from modules.services import getInterface as getServices +services = getServices(rootUser) + +# Generate magic link +frontendUrl = APP_CONFIG.get("Frontend_BASE_URL", "https://playground.poweron-center.net") +magicLink = f"{frontendUrl}/reset.html?token={resetToken}" + +# Prepare email content +emailSubject = "PowerOn Registration - Set Your Password" +emailBody = f""" +Hello {user.fullName or user.username}, + +Thank you for registering with PowerOn. + +Please click the link below to set your password: +{magicLink} + +This link will expire in {expiryHours} hours. + +If you did not register, please ignore this email. +""" + +# Send email +emailSent = services.messaging.sendEmailDirect( + recipient=user.email, + subject=emailSubject, + message=emailBody, + userId=str(user.id) +) + +if not emailSent: + logger.error(f"Failed to send registration email to {user.email}") + # Don't fail registration if email fails - user can request reset later +``` + +**In Password Reset Request Endpoint**: +```python +# Similar pattern - use services.messaging.sendEmailDirect() +emailSubject = "PowerOn Password Reset" +emailBody = f""" +Hello {user.fullName or user.username}, + +You requested a password reset for your PowerOn account. + +Please click the link below to reset your password: +{magicLink} + +This link will expire in {expiryHours} hours. + +If you did not request this, please ignore this email. +""" + +emailSent = services.messaging.sendEmailDirect( + recipient=user.email, + subject=emailSubject, + message=emailBody, + userId=str(user.id) +) +``` + +#### Alternative: Helper Function in interfaceDbAppObjects + +Alternatively, create a helper function in `interfaceDbAppObjects.py` that wraps the messaging service: + +```python +def sendPasswordResetEmail( + self, + user: User, + token: str, + emailType: str # "registration" or "reset" +) -> bool: + """ + Send password reset/registration email to user. + + Args: + user: User object + token: Reset token UUID + emailType: "registration" or "reset" + + Returns: + bool: True if email sent successfully + """ + try: + # Get frontend URL from config + from modules.shared.configuration import APP_CONFIG + frontendUrl = APP_CONFIG.get("Frontend_BASE_URL", "https://playground.poweron-center.net") + magicLink = f"{frontendUrl}/reset.html?token={token}" + + # Get expiry hours from config + expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24")) + + # Prepare email content based on type + if emailType == "registration": + subject = "PowerOn Registration - Set Your Password" + body = f""" +Hello {user.fullName or user.username}, + +Thank you for registering with PowerOn. + +Please click the link below to set your password: +{magicLink} + +This link will expire in {expiryHours} hours. + +If you did not register, please ignore this email. +""" + else: # reset + subject = "PowerOn Password Reset" + body = f""" +Hello {user.fullName or user.username}, + +You requested a password reset for your PowerOn account. + +Please click the link below to reset your password: +{magicLink} + +This link will expire in {expiryHours} hours. + +If you did not request this, please ignore this email. +""" + + # Get services instance + from modules.services import getInterface as getServices + services = getServices(self.currentUser) + + # Send email + return services.messaging.sendEmailDirect( + recipient=user.email, + subject=subject, + message=body, + userId=str(user.id) + ) + except Exception as e: + logger.error(f"Error sending password reset email to {user.email}: {str(e)}", exc_info=True) + return False +``` + +#### Configuration Requirements + +**File**: `gateway/config.ini` +```ini +# Frontend URL for magic link generation +Frontend_BASE_URL = https://playground.poweron-center.net +# Or use environment-specific values: +# Frontend_BASE_URL_INT = https://playground-int.poweron-center.net +# Frontend_BASE_URL_PROD = https://playground.poweron-center.net +``` + +**File**: `gateway/env_*.env` (environment-specific) +```env +# Email connector configuration (already exists for messaging service) +MESSAGING_ACS_CONNECTION_STRING=... +MESSAGING_ACS_SENDER_EMAIL=... +``` + +#### Implementation Status +- ✅ `serviceMessaging` exists and is integrated +- ✅ `interfaceMessaging` exists +- ✅ Email connector exists +- ⚠️ Need to add `sendEmailDirect()` method to `MessagingService` +- ⚠️ Need to add `sendPasswordResetEmail()` helper to `interfaceDbAppObjects` (optional, but recommended) +- ⚠️ Need to configure `Frontend_BASE_URL` in config + +#### Testing +- Test email sending with valid email addresses +- Test email sending failure handling (invalid email, connector errors) +- Test email content formatting (HTML vs plain text) +- Test magic link generation with different frontend URLs +- Test email sending in registration flow +- Test email sending in password reset flow + +### 2. Registration Process Changes + +#### Current Behavior +- User provides username, password, email, fullName, language +- Password is required and hashed immediately +- User is created with `enabled=False` and `roleLabels=["user"]` +- User is assigned to root mandate +- **Note**: MSFT/Google auth users are created with `enabled=True` (already implemented) + +#### User Activation Policy +- **LOCAL auth users**: After registration (with password set via magic link) → `enabled=True` +- **LOCAL auth users**: After password reset → `enabled=True` +- **MSFT/Google auth users**: On registration → `enabled=True` (already implemented) +- **Rationale**: Reduce admin workload - users can login immediately after completing registration/password reset + +#### New Behavior +- User provides username, email, fullName, language (NO password) +- **Email Uniqueness Check**: System checks if email is already used by any LOCAL auth user (across all mandates). If found, registration is rejected. +- User is created with: + - `passwordHash = None` (empty) + - `mandateId = root mandate ID` + - `enabled = True` (activated by default - no admin enablement needed) + - `roleLabels = ["user"]` + - `resetToken = ` (generated) + - `resetTokenExpires = ` (current UTC timestamp + expiration delay from config in seconds) +- Magic link is generated but NOT returned in API response +- Email is sent to user with magic link for password setup +- API response indicates: "Registration successful. Please check your email to set your password." + +#### Registration Endpoint Changes +**File**: `gateway/modules/routes/routeSecurityLocal.py` +- Modify `register_user()` endpoint (line 192) +- Remove password requirement +- **Check email uniqueness**: Verify email is not already used by any LOCAL auth user (across all mandates) +- **If email exists for LOCAL auth user**: Send password reset email to existing user (don't create duplicate), return generic success +- **If email is unique**: Create new user, generate reset token and expiration (using float timestamp), send email +- Set `enabled = True` (user activated by default) +- Return generic success message without magic link (don't reveal if email existed) + +### 2. Password Reset Button and Page + +#### New UI Element +**File**: `frontend_agents/public/login.html` +- Add "Password Reset" button (always visible, not conditional) +- Button links to `/password-reset-request.html` page + +#### New Password Reset Request Page +**File**: `frontend_agents/public/password-reset-request.html` +- Similar structure to register page +- Form field: Email address (required) +- On submit: + - Calls password reset request endpoint + - Shows generic success message (regardless of whether email exists) + - Redirects to login page after showing message + +#### New Endpoint +**File**: `gateway/modules/routes/routeSecurityLocal.py` +- Create `POST /api/local/password-reset-request` endpoint +- Accepts email address +- **Searches for user by email across ALL mandates** (using root interface) +- **Filters for LOCAL authentication authority only** +- If user found (first match): + - Generates new reset token and expiration (using float timestamp) + - Clears passwordHash (if exists) + - Sends email with magic link (via shared email sending function) +- If no user found: Do nothing (no email sent) +- **Always returns generic success message** (security: don't reveal if email exists) + +### 3. Password Reset Page + +#### New Page +**File**: `frontend_agents/public/reset.html` +- Similar structure to actan's `reset.html` +- Form fields: + - New Password (with strength validation) + - Confirm Password +- URL parameter: `?token=` (magic link token) +- On submit: + - Validates password strength (min 8 chars, requirements from config) + - Calls reset password endpoint with token and new password + - On success: Shows success message with spam folder reminder, redirects to login after 3 seconds + +#### Reset Password Endpoint +**File**: `gateway/modules/routes/routeSecurityLocal.py` +- Create `POST /api/local/password-reset` endpoint +- Accepts: + - `token`: UUID reset token + - `password`: New password +- Validates: + - Token exists and is not expired + - User has reset token set + - Password meets strength requirements +- Actions: + - Hash new password + - Set `passwordHash` in user record + - Clear `resetToken` and `resetTokenExpires` + - Set `enabled = True` (user activated by default) + - Return success + +### 4. Authentication Logic Updates + +#### Login Blocking Rules +**File**: `gateway/modules/interfaces/interfaceDbAppObjects.py` +- Update `authenticateLocalUser()` (line 529): + - Check if user has `resetToken` set → Block login + - Check if user has no `passwordHash` → Block login + - Error message: "Password reset required. Please check your email for the reset link." + +#### Current Check (Line 549) +```python +if not userRecord.get("hashedPassword"): + raise ValueError("User has no password set") +``` +This already blocks login if no password, but we need to also check for reset token. + +### 5. Configuration Changes + +#### New Config Variable +**File**: `gateway/config.ini` +- Add: `Auth_RESET_TOKEN_EXPIRY_HOURS = 24` +- This defines how long magic links remain valid (24 hours) + +### 6. User Data Model Changes + +#### Extended User Model +**File**: `gateway/modules/datamodels/datamodelUam.py` + +Add fields to `UserInDB` model: +```python +resetToken: Optional[str] = Field(None, description="Password reset token (UUID)") +resetTokenExpires: Optional[float] = Field(None, description="Reset token expiration (UTC timestamp in seconds)") +``` + +**Note**: `resetTokenExpires` uses float timestamp format (UTC timestamp in seconds) consistent with other timestamp fields like `connectedAt`, `lastChecked`, `expiresAt` in the `UserConnection` model. Use `getUtcTimestamp()` from `modules.shared.timeUtils` and add expiration hours converted to seconds. + +**Example usage for expiration calculation**: +```python +from modules.shared.timeUtils import getUtcTimestamp +from modules.shared.configuration import APP_CONFIG + +expiry_hours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24")) +resetTokenExpires = getUtcTimestamp() + (expiry_hours * 3600) +``` + +**Note**: These fields should NOT be in the public `User` model (security), only in `UserInDB`. + +## Architecture Overview + +### Data Flow: Registration +``` +1. User submits registration form (no password) + ↓ +2. Frontend calls POST /api/local/register + ↓ +3. Backend: + - Validates username availability + - Checks email uniqueness (no LOCAL auth user with same email exists) + - Creates user with: + * passwordHash = None + * resetToken = + * resetTokenExpires = + * enabled = True (activated by default) + * mandateId = root mandate + * roleLabels = ["user"] + - Generates magic link: /reset.html?token= + - Sends email via shared email sending function + ↓ +4. Returns: {"message": "Registration successful. Check your email."} + ↓ +5. Frontend shows success with spam folder reminder, redirects to login +``` + +### Data Flow: Password Reset Request +``` +1. User clicks "Password Reset" button on login page + ↓ +2. User navigates to /password-reset-request.html + ↓ +3. User enters email address and submits + ↓ +4. Frontend calls POST /api/local/password-reset-request + ↓ +5. Backend: + - Searches for user by email across ALL mandates (root interface) + - Filters for LOCAL authentication authority only + - If user found (first match): + * Generates new resetToken and resetTokenExpires (UTC timestamp float) + * Clears passwordHash (if exists) + * Sends email with magic link via shared email sending function + - If no user found: Do nothing (no email sent) + ↓ +6. Always returns: {"message": "If email exists, reset link sent."} + ↓ +7. Frontend shows generic success message with spam folder reminder, redirects to login +``` + +### Data Flow: Password Reset (via Magic Link) +``` +1. User clicks magic link: /reset.html?token= + ↓ +2. Frontend loads reset.html, extracts token from URL + ↓ +3. User enters new password (twice for confirmation) + ↓ +4. Frontend calls POST /api/local/password-reset + ↓ +5. Backend: + - Validates token exists and not expired (compare resetTokenExpires float with current UTC timestamp) + - Validates password strength + - Sets passwordHash = hash(newPassword) + - Clears resetToken and resetTokenExpires + - Sets enabled = True (user activated by default) + ↓ +6. Returns: {"message": "Password reset successful"} + ↓ +7. Frontend shows success message with spam folder reminder, redirects to login after 3s +``` + +### Data Flow: Login (Updated) +``` +1. User submits login form + ↓ +2. Frontend calls POST /api/local/login + ↓ +3. Backend authenticateLocalUser(): + - Checks user exists + - Checks user.enabled == True + - Checks authenticationAuthority == LOCAL + - Checks passwordHash exists → Block if missing + - Checks resetToken is None → Block if exists + - Verifies password + ↓ +4. If all checks pass: Create session, return tokens + If blocked: Return error "Password reset required" +``` + +## Data Objects + +### UserInDB Model (Extended) +```python +class UserInDB(User): + hashedPassword: Optional[str] = Field(None, description="Hash of the user password") + resetToken: Optional[str] = Field(None, description="Password reset token (UUID)") + resetTokenExpires: Optional[float] = Field(None, description="Reset token expiration (UTC timestamp in seconds)") +``` + +### Reset Token Format +- **Type**: UUID v4 (string) +- **Storage**: Stored in `resetToken` field +- **Expiration**: UTC timestamp as float (seconds since epoch) in `resetTokenExpires` field +- **Example**: + - `resetToken`: `"a1b2c3d4-e5f6-7890-abcd-ef1234567890"` + - `resetTokenExpires`: `1737374400.0` (UTC timestamp float in seconds) +- **Calculation**: `getUtcTimestamp() + (expiry_hours * 3600)` where expiry_hours comes from config + +### Magic Link Format +- **URL**: `/reset.html?token=` +- **Example**: `/reset.html?token=a1b2c3d4-e5f6-7890-abcd-ef1234567890` +- **Base URL**: Should use frontend base URL from config + +## Email Templates + +### Registration Email +**Subject**: "PowerOn Registration - Set Your Password" +**Body**: +``` +Hello {fullName}, + +Thank you for registering with PowerOn. + +Please click the link below to set your password: +{magic_link} + +This link will expire in {expiry_hours} hours. + +If you did not register, please ignore this email. +``` + +### Password Reset Email +**Subject**: "PowerOn Password Reset" +**Body**: +``` +Hello {fullName}, + +You requested a password reset for your PowerOn account. + +Please click the link below to reset your password: +{magic_link} + +This link will expire in {expiry_hours} hours. + +If you did not request this, please ignore this email. +``` + +## Security Considerations + +### Token Security +- Reset tokens are UUIDs (cryptographically random) +- Tokens are single-use (cleared immediately after successful password reset) +- **Token invalidation**: When new reset token is generated, **old token is overwritten** in database (only one token exists per user) +- **No multiple tokens risk**: Token is stored in single field in UserInDB record - only last token is valid +- Tokens expire after configurable time period (24 hours) +- **Expired token cleanup**: Expired tokens are checked and cleared during authentication and token validation +- Tokens are not returned in API responses (only sent via email) +- **Token reuse prevention**: After password reset, token is cleared atomically with password setting (no reuse possible) +- **Rate limiting**: Password reset request endpoint limited to prevent abuse (see API Endpoints) + +### Password Security +- Passwords are hashed using bcrypt (existing implementation) +- Password strength requirements enforced (from config) +- Empty passwords block login +- Reset tokens block login until password is set + +### User State Management +- Users with reset tokens cannot login +- Users without passwords cannot login +- **Users are enabled (`enabled=True`) by default** after registration and password reset +- **No admin activation required** - users can login immediately after setting password +- Exception: Users registered via MSFT/Google are also `enabled=True` by default +- **Email normalization**: All emails are stored and compared in lowercase to prevent case-sensitivity issues +- **Atomic operations**: Password reset operations use database transactions to ensure consistency + +## Implementation Status + +### ✅ Already Implemented +- Login page with MSFT/Google/Local auth +- Registration page (but requires password) +- User data model (`User`, `UserInDB`) +- Authentication logic (`authenticateLocalUser()`) +- Password hashing (`_getPasswordHash()`, `_verifyPassword()`) +- Admin password reset endpoint (`/api/users/{userId}/reset-password`) + +### ❌ Not Yet Implemented +- Reset token fields in `UserInDB` model +- Password reset request endpoint +- Password reset endpoint (public, token-based) +- Email sending functionality +- Magic link generation +- Password reset pages (request and reset) +- Password reset API calls in frontend +- Registration without password +- Reset token validation in authentication + +## Implementation Checklist (Ready for Development) + +### Phase 1: Critical Bug Fix + Data Model (🔴 MUST DO FIRST) + +#### 1.1 Fix Missing `resetUserPassword()` Method +**File**: `gateway/modules/interfaces/interfaceDbAppObjects.py` +**Insert after line 741** (after `enableUser` method): + +```python +def resetUserPassword(self, userId: str, newPassword: str) -> bool: + """Reset a user's password (admin function).""" + try: + if not newPassword or len(newPassword) < 8: + raise ValueError("Password must be at least 8 characters long") + + hashedPassword = self._getPasswordHash(newPassword) + self.db.recordModify(UserInDB, userId, {"hashedPassword": hashedPassword}) + logger.info(f"Password reset for user {userId}") + return True + except Exception as e: + logger.error(f"Error resetting password for user {userId}: {str(e)}") + return False +``` + +#### 1.2 Add Reset Token Fields to UserInDB +**File**: `gateway/modules/datamodels/datamodelUam.py` +**Modify lines 166-172**: + +```python +class UserInDB(User): + hashedPassword: Optional[str] = Field(None, description="Hash of the user password") + resetToken: Optional[str] = Field(None, description="Password reset token (UUID)") + resetTokenExpires: Optional[float] = Field(None, description="Reset token expiration (UTC timestamp in seconds)") +``` + +### Phase 2: Email Sending + Helper Methods + +#### 2.1 Add `sendEmailDirect()` to MessagingService +**File**: `gateway/modules/services/serviceMessaging/mainServiceMessaging.py` +**Insert after line 131** (after `sendMessage` method) - see Section 1 above for code. + +#### 2.2 Add Helper Methods to `interfaceDbAppObjects.py` +**File**: `gateway/modules/interfaces/interfaceDbAppObjects.py` + +```python +# Insert after resetUserPassword() method + +def generateResetTokenAndExpiry(self) -> tuple[str, float]: + """Generate a new reset token and expiration timestamp. + + Returns: + tuple: (token_uuid, expires_timestamp_float) + """ + import uuid + token = str(uuid.uuid4()) + expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24")) + expires = getUtcTimestamp() + (expiryHours * 3600) + return token, expires + +def findUserByEmailLocalAuth(self, email: str) -> Optional[User]: + """Find LOCAL auth user by email (searches across all mandates). + + Args: + email: Email address to search for (case-insensitive) + + Returns: + User if found, None otherwise + """ + if not email: + return None + + normalizedEmail = email.lower().strip() + + try: + # Use root interface to bypass RBAC for cross-mandate search + users = self.db.getRecordset( + UserInDB, + recordFilter={ + "email": normalizedEmail, + "authenticationAuthority": AuthAuthority.LOCAL.value + } + ) + + if users: + cleanedUser = {k: v for k, v in users[0].items() if not k.startswith("_")} + if cleanedUser.get("roleLabels") is None: + cleanedUser["roleLabels"] = [] + return User(**cleanedUser) + + return None + except Exception as e: + logger.error(f"Error finding user by email: {str(e)}") + return None + +def setResetToken(self, userId: str, token: str, expires: float) -> bool: + """Set reset token for a user (clears password hash).""" + try: + self.db.recordModify(UserInDB, userId, { + "resetToken": token, + "resetTokenExpires": expires, + "hashedPassword": None # Clear password during reset flow + }) + return True + except Exception as e: + logger.error(f"Error setting reset token for user {userId}: {str(e)}") + return False + +def verifyResetToken(self, token: str) -> Optional[User]: + """Verify reset token and return user if valid. + + Returns: + User if token is valid and not expired, None otherwise + """ + if not token: + return None + + try: + users = self.db.getRecordset(UserInDB, recordFilter={"resetToken": token}) + + if not users: + return None + + userRecord = users[0] + + # Check expiration + expires = userRecord.get("resetTokenExpires") + if not expires or getUtcTimestamp() > expires: + logger.warning(f"Reset token expired for user {userRecord.get('id')}") + return None + + cleanedUser = {k: v for k, v in userRecord.items() if not k.startswith("_")} + if cleanedUser.get("roleLabels") is None: + cleanedUser["roleLabels"] = [] + return User(**cleanedUser) + except Exception as e: + logger.error(f"Error verifying reset token: {str(e)}") + return None + +def resetPasswordWithToken(self, token: str, newPassword: str) -> bool: + """Reset password using token (atomic operation). + + Returns: + True if successful, False otherwise + """ + try: + user = self.verifyResetToken(token) + if not user: + return False + + hashedPassword = self._getPasswordHash(newPassword) + + # Atomic update: set password, clear token, enable user + self.db.recordModify(UserInDB, user.id, { + "hashedPassword": hashedPassword, + "resetToken": None, + "resetTokenExpires": None, + "enabled": True + }) + + logger.info(f"Password reset completed for user {user.id}") + return True + except Exception as e: + logger.error(f"Error in resetPasswordWithToken: {str(e)}") + return False +``` + +### Phase 3: Backend Endpoints + +#### 3.1 Add Configuration +**File**: `gateway/config.ini` +**Add at end of Auth section**: + +```ini +# Reset token configuration +Auth_RESET_TOKEN_EXPIRY_HOURS = 24 + +# Frontend URL for magic links +Frontend_BASE_URL = https://playground.poweron-center.net +``` + +#### 3.2 Add Password Reset Endpoints +**File**: `gateway/modules/routes/routeSecurityLocal.py` +**Add after `/available` endpoint (after line 435)**: + +```python +@router.post("/password-reset-request") +@limiter.limit("5/minute") +async def passwordResetRequest( + request: Request, + email: str = Body(..., embed=True) +) -> Dict[str, Any]: + """Request password reset email.""" + try: + rootInterface = getRootInterface() + + # Normalize email + normalizedEmail = email.lower().strip() + + # Find user (don't reveal if found or not) + user = rootInterface.findUserByEmailLocalAuth(normalizedEmail) + + if user: + # Generate reset token + token, expires = rootInterface.generateResetTokenAndExpiry() + + # Set reset token (clears password) + rootInterface.setResetToken(user.id, token, expires) + + # Get services for email sending + from modules.services import Services + services = Services(user) + + # Generate magic link + frontendUrl = APP_CONFIG.get("Frontend_BASE_URL", "https://playground.poweron-center.net") + magicLink = f"{frontendUrl}/reset.html?token={token}" + + # Send email + emailSubject = "PowerOn - Passwort zurücksetzen" + emailBody = f""" +Hallo {user.fullName or user.username}, + +Sie haben eine Passwort-Zurücksetzung für Ihren PowerOn Account angefordert. + +Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen: +{magicLink} + +Dieser Link ist 24 Stunden gültig. + +Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren. +""" + + services.messaging.sendEmailDirect( + recipient=user.email, + subject=emailSubject, + message=emailBody, + userId=str(user.id) + ) + + logger.info(f"Password reset email sent to {normalizedEmail}") + else: + logger.info(f"Password reset requested for unknown email: {normalizedEmail}") + + # Always return same message (security) + return { + "message": "Falls ein Konto mit dieser E-Mail existiert, wurde ein Reset-Link gesendet." + } + + except Exception as e: + logger.error(f"Error in password reset request: {str(e)}") + # Still return success for security + return { + "message": "Falls ein Konto mit dieser E-Mail existiert, wurde ein Reset-Link gesendet." + } + +@router.post("/password-reset") +@limiter.limit("10/minute") +async def passwordReset( + request: Request, + token: str = Body(..., embed=True), + password: str = Body(..., embed=True) +) -> Dict[str, Any]: + """Reset password using token from magic link.""" + try: + # Validate token format (UUID) + try: + uuid.UUID(token) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ungültiger oder abgelaufener Reset-Link" + ) + + # Validate password strength + if len(password) < 8: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Passwort muss mindestens 8 Zeichen lang sein" + ) + + rootInterface = getRootInterface() + + # Verify and reset + success = rootInterface.resetPasswordWithToken(token, password) + + if not success: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ungültiger oder abgelaufener Reset-Link" + ) + + # Log success + try: + from modules.shared.auditLogger import audit_logger + audit_logger.logSecurityEvent( + userId="unknown", + mandateId="unknown", + action="password_reset_via_token", + details="Password reset completed via magic link" + ) + except Exception: + pass + + return {"message": "Passwort erfolgreich gesetzt"} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error in password reset: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Passwort-Zurücksetzung fehlgeschlagen" + ) +``` + +#### 3.3 Modify Registration Endpoint +**File**: `gateway/modules/routes/routeSecurityLocal.py` +**Modify `register_user()` (lines 192-248)** - Remove password requirement, change to email-based: + +Key changes: +- Remove `password: str = Body(..., embed=True)` from line 197 +- Change `enabled=False` to `enabled=True` on line 225 +- Add reset token generation and email sending + +### Phase 4: Frontend Changes + +#### 4.1 Modify `login.html` +**Add after line 34** (after submit button, before register-options): + +```html + +``` + +#### 4.2 Modify `register.html` +**Remove lines 23-31** (password and confirm-password fields) +**Add info message after form** (before submit button): + +```html +
+

Nach der Registrierung erhalten Sie eine E-Mail mit einem Link zum Setzen Ihres Passworts.

+
+``` + +#### 4.3 Add to `apiCalls.js` +**Add after `register` function (after line 379)**: + +```javascript +requestPasswordReset: async function(email) { + try { + return await privateApi.post('/api/local/password-reset-request', { email }); + } catch (error) { + ui.log.error('Password reset request error:', error); + throw error; + } +}, + +resetPassword: async function(token, password) { + try { + return await privateApi.post('/api/local/password-reset', { token, password }); + } catch (error) { + ui.log.error('Password reset error:', error); + throw error; + } +}, +``` + +#### 4.4 Create New Files +- `password-reset-request.html` - See `doc_userauth_ui_adaptations.md` +- `reset.html` - See `doc_userauth_ui_adaptations.md` +- `js/security/passwordResetRequest.js` +- `js/security/reset.js` + +### Phase 5: Authentication Logic Update + +**File**: `gateway/modules/interfaces/interfaceDbAppObjects.py` +**Modify `authenticateLocalUser()` (lines 570-596)** - Add reset token check: + +```python +# After line 591 (after hashedPassword check), add: +if userRecord.get("resetToken"): + raise ValueError("Passwort-Zurücksetzung erforderlich. Bitte prüfen Sie Ihre E-Mail.") +``` + +## Testing Scenarios + +### Registration Flow +1. ✅ User registers without password → User created with reset token +2. ✅ Email uniqueness checked (reject if email already used by LOCAL auth user) +3. ✅ Email sent with magic link +4. ✅ User cannot login before setting password +5. ✅ Magic link expires after 24 hours +6. ✅ User can set password via magic link +7. ✅ User can login immediately after password reset (enabled=True) +8. ✅ No admin activation required + +### Password Reset Flow +1. ✅ User clicks "Password Reset" button on login page +2. ✅ User navigates to password-reset-request.html +3. ✅ User enters email → System searches across all mandates for LOCAL auth user +4. ✅ If user found: Reset token generated and email sent +5. ✅ If user not found: No email sent, but generic success message shown +6. ✅ User cannot login with old password (if existed) +7. ✅ User sets new password via magic link +8. ✅ User can login immediately after password reset (enabled=True) +9. ✅ No admin activation required + +### Login Blocking +1. ✅ User with no password cannot login +2. ✅ User with reset token cannot login (even if expired token exists) +3. ✅ User with valid password can login (if enabled=True) +4. ✅ User with expired reset token cannot login +5. ✅ User with reset token cannot login even if passwordHash exists (reset token takes precedence) + +### Additional Security Tests +1. ✅ Multiple reset requests invalidate old tokens +2. ✅ Token reuse attempt fails (token cleared after use) +3. ✅ Email case variations handled (normalized to lowercase) +4. ✅ Rate limiting prevents abuse +5. ✅ Atomic operations prevent partial updates +6. ✅ Expired tokens are rejected +7. ✅ Invalid token formats are rejected +8. ✅ Cross-mandate email search works correctly + +## Reference Implementation + +The actan project (`actan/sanctions-code`) provides a reference implementation: + +### Key Files: +- `server/core/usermanagement.py`: + - `set_password_reset()` (line 371): Sets reset token and clears password + - `verify_reset_token()` (line 422): Validates token + - `set_new_password()` (line 438): Sets password and clears token + - `is_user_in_password_reset()` (line 504): Checks reset state + +- `server/core/authentication.py`: + - `verify_password()` (line 38): Checks for empty password hash + - `authenticate_user()` (line 206): Blocks login if changepwd=True + +- `public/reset.html`: + - Password reset page UI + - Token extraction from URL + - Form submission handling + +- `public/js/login.js`: + - Login form handling + - Error message display + +## Migration Notes + +### Existing Users +- Existing users with passwords are unaffected +- No migration needed for existing data +- New fields (`resetToken`, `resetTokenExpires`) are optional and nullable + +### Database Schema +- Add columns to users table: + - `reset_token` VARCHAR(36) NULL (UUID format) + - `reset_token_expires` DOUBLE PRECISION NULL (UTC timestamp float in seconds) +- Both fields default to NULL +- No data migration required + +## Configuration Variables + +### New Config Entries + +**Backend** (`gateway/config.ini`): +```ini +# Auth configuration +Auth_RESET_TOKEN_EXPIRY_HOURS = 24 +``` + +**Frontend** (`frontend_agents/public/config/env_*.env`): +```ini +# Frontend URL for magic link generation (add to each env file) +APP_FRONTEND_URL = https://your-frontend-domain.com +# Examples: +# env_int.env: APP_FRONTEND_URL = https://playground-int.poweron-center.net +# env_prod.env: APP_FRONTEND_URL = https://playground.poweron-center.net +``` + +### Usage +- Read from config in registration and reset endpoints +- Calculate expiration: `getUtcTimestamp() + (expiry_hours * 3600)` +- Store as float: `resetTokenExpires = current_timestamp + (24 * 3600)` (for 24 hours) +- Compare expiration: `if resetTokenExpires > getUtcTimestamp(): valid` + +## API Endpoints Summary + +### Modified Endpoints +- `POST /api/local/register` + - **Change**: Remove password requirement + - **Add**: Generate reset token, send email + - **Response**: Success message (no token) + +### New Endpoints +- `POST /api/local/password-reset-request` + - **Rate limit**: `5/minute` (stricter than registration to prevent abuse) + - **Input**: `{ "email": "user@example.com" }` (email normalized to lowercase) + - **Output**: `{ "message": "If email exists, reset link sent." }` (always same message) + - **Behavior**: Invalidates old reset token if user found, generates new one + +- `POST /api/local/password-reset` + - **Rate limit**: `10/minute` + - **Input**: `{ "token": "", "password": "newpassword" }` + - **Output**: `{ "message": "Password reset successful" }` + - **Behavior**: Atomic operation - sets password, clears token, enables user in single transaction + +## Error Handling + +### Registration Errors +- Username already exists → 400 Bad Request +- **Email already exists for LOCAL auth user**: Send password reset email to existing user, return generic success (don't reveal email existed) +- Email sending fails → Log error, still return success (don't reveal email issues) + +### Password Reset Request Errors +- Email not found → Return generic success (security) +- Email sending fails → Log error, return generic success + +### Password Reset Errors +- Token format invalid → 400 Bad Request "Invalid or expired reset token" (generic message) +- Token not found → 400 Bad Request "Invalid or expired reset token" (generic message) +- Token expired (resetTokenExpires < current UTC timestamp) → 400 Bad Request "Invalid or expired reset token" +- Token already used (passwordHash exists, resetToken is None) → 400 Bad Request "Invalid or expired reset token" +- User not found → 400 Bad Request "Invalid or expired reset token" (generic message) +- Password too weak → 400 Bad Request with specific requirements (safe to reveal) +- User disabled → 400 Bad Request "Account is disabled" (or allow reset but keep disabled) + +## Next Steps + +1. **Review and approve this concept document** +2. **Create detailed implementation tasks** +3. **Implement backend changes** (data model, endpoints, authentication logic) +4. **Implement frontend changes** (UI, API calls, reset page) +5. **Integrate email sending** (messaging service) +6. **Test all flows** (registration, reset request, reset, login blocking) +7. **Update documentation** (user guide, admin guide) + +## Code Reuse Strategy + +### Shared Functions - Frontend + +1. **Email Validation** (`frontend_agents/public/js/security/auth.js`) + - Create `validateEmailFormat(email)` function + - Reused by: + * Registration page (`register.html`) + * Password reset request page (`password-reset-request.html`) + - Validates email format using regex + +### Shared Functions - Backend + +1. **Email Uniqueness Check** (`gateway/modules/interfaces/interfaceDbAppObjects.py`) + - Create `checkEmailUniquenessForLocalAuth(email)` method + - Searches across ALL mandates for LOCAL auth users with same email + - Returns: `True` if email is unique, `False` if already exists + - Reused by: + * Registration endpoint (reject if email exists) + * Could be used for future email change validation + +2. **Find User by Email** (`gateway/modules/interfaces/interfaceDbAppObjects.py`) + - Create `findUserByEmailLocalAuth(email)` method + - **Email normalization**: Convert email to lowercase before search + - Uses root interface to search across ALL mandates (bypasses RBAC) + - Searches with filter: `{"email": email.lower(), "authenticationAuthority": AuthAuthority.LOCAL}` + - Returns: First matching User or None + - Handles NULL emails gracefully + - Reused by: + * Password reset request endpoint + * Email uniqueness check + +3. **Token Generation** (`gateway/modules/interfaces/interfaceDbAppObjects.py`) + - Create `generateResetTokenAndExpiry()` helper method + - Returns tuple: `(token_uuid: str, expires_timestamp_float: float)` + - Uses `uuid.uuid4()` for token generation (cryptographically random) + - Uses `getUtcTimestamp() + (expiry_hours * 3600)` for expiration + - **Token invalidation**: Before generating new token, clears any existing resetToken for user + - Reused by: + * Registration endpoint + * Password reset request endpoint + +4. **Email Sending** (`gateway/modules/interfaces/interfaceDbAppObjects.py` or separate utility module) + - Create `sendPasswordResetEmail(user, token, emailType)` function + - Parameters: + * `user`: User object (for fullName, email) + * `token`: UUID token string + * `emailType`: "registration" or "reset" (determines email template) + - Generates magic link: `{APP_FRONTEND_URL}/reset.html?token=` + - Uses frontend base URL from frontend env config (`APP_FRONTEND_URL`) + - Backend reads frontend URL from request origin or frontend config + - Sends email via messaging service + - **Error handling**: Logs email sending failures but doesn't fail the operation + - Reused by: + * Registration endpoint (emailType="registration") + * Password reset request endpoint (emailType="reset") + +## Security Considerations (Additional) + +### Email Normalization +- **All emails are normalized to lowercase** before storage and comparison +- Prevents case-sensitivity issues: `User@Example.com` = `user@example.com` +- Applied in: registration, password reset request, email uniqueness check + +### Token Invalidation +- **When new reset token is generated, old token is invalidated** +- Prevents multiple valid tokens per user +- Ensures user always knows which token to use (the most recent one) + +### Atomic Operations +- **Password reset uses database transactions** +- Ensures all-or-nothing: password set + token cleared + user enabled +- Prevents partial updates if operation fails + +### Rate Limiting +- **Registration**: 10/minute (existing) +- **Password reset request**: 5/minute (stricter to prevent abuse) +- **Password reset**: 10/minute (less critical, token is single-use) + +### Audit Logging +- Log all password reset requests (with email hash for privacy) +- Log successful password resets +- Log failed token validations (for security monitoring) +- Log token generation events + +## Questions / Open Points - Final Status + +| # | Item | Status | Resolution | +|---|------|--------|------------| +| 1 | Email Service | ✅ RESOLVED | `serviceMessaging` exists. Add `sendEmailDirect()` method. Azure ACS config in `env_*.env` | +| 2 | Root Mandate | ✅ RESOLVED | `getInitialId(Mandate)` used in lines 61, 206 of `routeSecurityLocal.py` | +| 3 | Password Strength | ✅ RESOLVED | Config exists. Min 8 chars enforced. Full validation optional (future) | +| 4 | Token Format | ✅ RESOLVED | UUID v4 string + float timestamp (matches `UserConnection` pattern) | +| 5 | Email Templates | ✅ DECIDED | Hardcode in German for MVP. i18n can be added later based on `user.language` | +| 6 | User Activation | ✅ DECIDED | Change `enabled=False` to `enabled=True` in registration | +| 7 | Token Cleanup | ✅ DECIDED | Lazy cleanup on validation. No periodic job needed | +| 8 | Resend Email | 📋 BACKLOG | Future enhancement. Not in MVP scope | +| 9 | `resetUserPassword()` | 🚨 **BUG FIX** | **MUST implement** - currently causes runtime error | +| 10 | Frontend Base URL | ✅ DECIDED | Use `Frontend_BASE_URL` in `config.ini`. Can be overridden per environment | + +### Implementation Decision Log + +1. **Email Language**: German (matches current UI language in `login.html`, `register.html`) +2. **Token Expiry**: 24 hours (configurable via `Auth_RESET_TOKEN_EXPIRY_HOURS`) +3. **Rate Limits**: + - Password reset request: 5/minute (stricter to prevent abuse) + - Password reset: 10/minute + - Registration: 10/minute (existing) +4. **Error Messages**: Generic for security (don't reveal if email exists) +5. **Password cleared on reset request**: Yes (user must set new password via link) diff --git a/implementation/implementation_doc_userauth_ui_adaptations.md b/implementation/implementation_doc_userauth_ui_adaptations.md new file mode 100644 index 0000000..ba0cffc --- /dev/null +++ b/implementation/implementation_doc_userauth_ui_adaptations.md @@ -0,0 +1,400 @@ +# User Authentication UI Adaptations + +## Overview + +This document describes the necessary UI changes and adaptations required to implement the magic link-based user authentication process described in `doc_userauth_process_concept.md`. + +**Last Updated**: Based on codebase analysis of current frontend implementation. + +## Current Frontend State + +### Existing Pages +1. **`frontend_agents/public/login.html`** + - Contains login form with username/password fields + - Has buttons for Microsoft and Google authentication + - Has registration link + - **Missing**: Password reset button + +2. **`frontend_agents/public/register.html`** + - Contains registration form with: + - Username field + - Password field (required) + - Confirm password field (required) + - Email field + - Full name field + - Language selector + - **Needs modification**: Remove password fields, add email-only registration + +3. **`frontend_agents/public/js/security/auth.js`** + - Contains `setupRegisterPage()` function + - Contains `validateRegistrationForm()` function that requires password + - Contains email validation logic + - **Needs modification**: Remove password validation, add shared email validation function + +4. **`frontend_agents/public/js/shared/apiCalls.js`** + - Contains `register()` function that sends password + - **Missing**: Password reset request and reset password API calls + +## Required UI Changes + +### 1. Login Page (`frontend_agents/public/login.html`) + +#### Changes Required: +- Add "Password Reset" button/link below the login form +- Button should link to `/password-reset-request.html` +- Style should match existing button styles (use `btn` class with appropriate variant) + +#### Implementation: +```html + + +``` + +#### Styling Considerations: +- Use existing CSS classes from `htmlparts/styles.css` +- Match styling with registration link +- Ensure responsive design matches login page layout + +### 2. Registration Page (`frontend_agents/public/register.html`) + +#### Changes Required: +- **Remove** password and confirm password fields +- **Keep** username, email, fullName, language fields +- Update form validation to not require password +- Update success message to indicate email will be sent +- Add spam folder reminder message + +#### Implementation: +```html + + + + +
+

Nach der Registrierung erhalten Sie eine E-Mail mit einem Link zum Setzen Ihres Passworts.

+

Bitte prĂĽfen Sie auch Ihren Spam-Ordner, falls Sie keine E-Mail erhalten.

+
+``` + +#### JavaScript Changes (`frontend_agents/public/js/security/auth.js`): +- Update `validateRegistrationForm()` to remove password validation +- Update `setupRegisterPage()` to handle no-password registration +- Create shared `validateEmailFormat()` function for reuse + +### 3. New Page: Password Reset Request (`frontend_agents/public/password-reset-request.html`) + +#### Purpose: +Allow users to request a password reset by entering their email address. + +#### Structure: +- Similar layout to `register.html` +- Single email input field +- Submit button +- Link back to login page +- Success/error message area + +#### Implementation: +```html + + + + + + PowerOn - Passwort zurücksetzen + + + + + + + + + + + + + + + +``` + +#### JavaScript Module (`frontend_agents/public/js/security/passwordResetRequest.js`): +- Handle form submission +- Validate email format using shared function from `auth.js` +- Call password reset request API +- Show generic success message +- Redirect to login page after showing message + +### 4. New Page: Password Reset (`frontend_agents/public/reset.html`) + +#### Purpose: +Allow users to set a new password using the token from the magic link. + +#### Structure: +- Similar layout to `register.html` +- Password field (with strength indicator) +- Confirm password field +- Submit button +- Extract token from URL parameter (`?token=`) +- Success/error message area + +#### Implementation: +```html + + + + + + PowerOn - Neues Passwort setzen + + + + + + + + + + + + + + + +``` + +#### JavaScript Module (`frontend_agents/public/js/security/reset.js`): +- Extract token from URL parameter +- Validate token format (UUID) +- Handle form submission +- Validate password strength (min 8 chars, match requirements from config) +- Validate password confirmation matches +- Call password reset API +- Show success message with spam folder reminder +- Redirect to login page after 3 seconds + +### 5. API Calls (`frontend_agents/public/js/shared/apiCalls.js`) + +#### New Functions Required: + +1. **`requestPasswordReset(email)`** + ```javascript + requestPasswordReset: async function(email) { + try { + return await privateApi.post('/api/local/password-reset-request', { email }); + } catch (error) { + ui.log.error('Password reset request error:', error); + throw error; + } + } + ``` + +2. **`resetPassword(token, password)`** + ```javascript + resetPassword: async function(token, password) { + try { + return await privateApi.post('/api/local/password-reset', { token, password }); + } catch (error) { + ui.log.error('Password reset error:', error); + throw error; + } + } + ``` + +3. **Update `register()` function** + - Remove password from request body + - Update to handle new registration flow (no password required) + +### 6. Shared Email Validation (`frontend_agents/public/js/security/auth.js`) + +#### New Function: +```javascript +/** + * Validates email format + * @param {string} email - Email address to validate + * @returns {boolean} - True if valid, false otherwise + */ +export function validateEmailFormat(email) { + if (!email) return false; + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email.trim()); +} +``` + +#### Usage: +- Used by registration form +- Used by password reset request form +- Ensures consistent email validation across the application + +## UI/UX Considerations + +### Error Handling + +1. **Registration Errors**: + - Username already exists → Show error on username field + - Email already exists → Show generic success (security: don't reveal email exists) + - Email sending fails → Show generic success (don't reveal email issues) + +2. **Password Reset Request Errors**: + - Invalid email format → Show error on email field + - Email not found → Show generic success (security: don't reveal email doesn't exist) + - Rate limiting → Show error message + +3. **Password Reset Errors**: + - Invalid/expired token → Show error message, link back to password reset request + - Password too weak → Show specific requirements + - Password mismatch → Show error on confirm password field + +### Success Messages + +1. **Registration Success**: + ``` + "Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail, um Ihr Passwort zu setzen. + Falls Sie keine E-Mail erhalten, prüfen Sie bitte auch Ihren Spam-Ordner." + ``` + +2. **Password Reset Request Success**: + ``` + "Falls ein Konto mit dieser E-Mail-Adresse existiert, wurde ein Reset-Link gesendet. + Bitte prüfen Sie Ihre E-Mail und auch Ihren Spam-Ordner." + ``` + +3. **Password Reset Success**: + ``` + "Passwort erfolgreich gesetzt! Sie werden zum Login weitergeleitet..." + ``` + +### Accessibility + +- All form fields should have proper labels +- Error messages should be associated with form fields using ARIA attributes +- Success messages should be announced to screen readers +- Form validation should provide clear, actionable feedback + +### Responsive Design + +- All pages should work on mobile devices +- Form layouts should adapt to smaller screens +- Buttons should be appropriately sized for touch interfaces +- Error messages should be readable on all screen sizes + +## Testing Checklist + +### Registration Flow +- [ ] User can register without password +- [ ] Email validation works correctly +- [ ] Success message displays correctly +- [ ] Redirect to login works +- [ ] Error handling for duplicate username +- [ ] Error handling for duplicate email (should show generic success) + +### Password Reset Request Flow +- [ ] User can access password reset request page from login +- [ ] Email validation works correctly +- [ ] Success message displays correctly +- [ ] Redirect to login works +- [ ] Error handling for invalid email format +- [ ] Error handling for rate limiting + +### Password Reset Flow +- [ ] User can access reset page with valid token +- [ ] Token extraction from URL works +- [ ] Password validation works correctly +- [ ] Password confirmation validation works +- [ ] Success message displays correctly +- [ ] Redirect to login works after 3 seconds +- [ ] Error handling for invalid token +- [ ] Error handling for expired token +- [ ] Error handling for weak password + +### Integration Testing +- [ ] End-to-end registration flow works +- [ ] End-to-end password reset flow works +- [ ] Email links work correctly +- [ ] Token expiration handling works +- [ ] Multiple reset requests invalidate old tokens + +## Implementation Order + +1. **Backend Changes First** (prerequisites): + - Add resetToken fields to UserInDB model + - Implement password reset endpoints + - Implement email sending functionality + +2. **Frontend API Layer**: + - Add password reset API calls to `apiCalls.js` + - Update registration API call + +3. **Frontend Pages**: + - Create `password-reset-request.html` + - Create `reset.html` + - Update `login.html` (add reset button) + - Update `register.html` (remove password fields) + +4. **Frontend JavaScript**: + - Create `passwordResetRequest.js` + - Create `reset.js` + - Update `auth.js` (remove password validation, add email validation function) + - Update `register.js` if needed + +5. **Testing**: + - Test each flow independently + - Test integration between frontend and backend + - Test error scenarios + - Test edge cases + +## Notes + +- All text should be in German to match existing UI (`login.html` uses German) +- CSS classes should match existing patterns from `htmlparts/styles.css` +- Form validation should use existing patterns from `auth.js` +- Error handling should use existing patterns from `auth.js` (showFieldError, clearFieldError) +- Success messages should use existing patterns (showSuccessMessage) +- API calls should use existing patterns from `apiCalls.js` (privateApi.post, handleResponse) diff --git a/reviews/connector_generic_refactoring_analysis.md b/reviews/connector_generic_refactoring_analysis.md new file mode 100644 index 0000000..34ab57d --- /dev/null +++ b/reviews/connector_generic_refactoring_analysis.md @@ -0,0 +1,265 @@ +# Connector Generic Refactoring Analysis + +## Problem +The `connectorDbPostgre.py` has **hardcoded field names** that make it non-generic: +- Lines 61-70: Hardcoded field names in `_get_model_fields()` to force JSONB type +- Lines 689-700: Hardcoded field names in `_loadTable()` for None value defaults +- Lines 891-902: Hardcoded field names in `getRecordset()` for None value defaults + +## Hardcoded Fields in Connector + +### Location 1: `_get_model_fields()` (Lines 61-70) +```python +or field_name in [ + "execParameters", + "expectedDocumentFormats", + "resultDocuments", + "logs", + "messages", + "stats", + "tasks", +] +``` + +### Location 2: `_loadTable()` (Lines 689-700) +```python +if field_name in [ + "logs", + "messages", + "tasks", + "expectedDocumentFormats", + "resultDocuments", +]: + record[field_name] = [] +elif field_name in ["execParameters", "stats"]: + record[field_name] = {} +``` + +### Location 3: `getRecordset()` (Lines 891-902) +```python +if field_name in [ + "logs", + "messages", + "tasks", + "expectedDocumentFormats", + "resultDocuments", +]: + record[field_name] = [] +elif field_name in ["execParameters", "stats"]: + record[field_name] = {} +``` + +--- + +## Field Usage Analysis + +### Fields Used in Models: + +1. **`logs`, `messages`, `stats`, `tasks`** → `ChatWorkflow` model (`datamodelChat.py`) + - Used by: `interfaceDbChatObjects.py` + - Type: `List[...]` (should be JSONB) + +2. **`execParameters`, `expectedDocumentFormats`** → `ActionItem` model (`datamodelChat.py`) + - Used by: `interfaceDbChatObjects.py` (via `AutomationDefinition`) + - Type: `Dict[str, Any]` and `Optional[List[Dict[str, str]]]` (should be JSONB) + +3. **`resultDocuments`** → Not found in current models (may be legacy or unused) + +--- + +## Current Interface Handling + +### ✅ `interfaceDbChatObjects.py` - HAS Field Separation Logic +- **Has:** `_separateObjectFields()` method (lines 210-254) +- **Handles:** Separates `documents`, `stats` to `objectFields` (lines 226-228) +- **Still has hardcoded fields:** Lines 234: `execParameters`, `expectedDocumentFormats`, `resultDocuments` +- **Status:** ✅ Partially handles field separation, but still relies on connector for JSONB detection + +### ❌ `interfaceDbComponentObjects.py` - NO Field Separation Logic +- **Has:** Direct calls to `db.recordCreate()`, `db.recordModify()` +- **Handles:** None - relies entirely on connector's hardcoded logic +- **Status:** ❌ Needs field separation logic + +### ❌ `interfaceDbAppObjects.py` - NO Field Separation Logic +- **Has:** Direct calls to `db.recordCreate()`, `db.recordModify()` +- **Handles:** None - relies entirely on connector's hardcoded logic +- **Status:** ❌ Needs field separation logic + +### ⚠️ `interfaceRbac.py` - HAS Hardcoded Field Names +- **Has:** Helper function `getRecordsetWithRBAC()` (lines 113-120) +- **Handles:** Hardcoded field names for None defaults (same as connector) +- **Status:** ⚠️ Also needs to be made generic + +--- + +## Required Changes + +### 1. **connectorDbPostgre.py** - REMOVE Hardcoded Logic + +#### Change 1: `_get_model_fields()` (Lines 43-87) +**REMOVE:** +```python +or field_name in [ + "execParameters", + "expectedDocumentFormats", + "resultDocuments", + "logs", + "messages", + "stats", + "tasks", +] +``` + +**REPLACE WITH:** Pure type-based detection only +```python +# Check for JSONB fields (Dict, List, or complex types) +if ( + field_type == dict + or field_type == list + or ( + hasattr(field_type, "__origin__") + and field_type.__origin__ in (dict, list) + ) +): + fields[field_name] = "JSONB" +``` + +#### Change 2: `_loadTable()` (Lines 689-700) +**REMOVE:** Hardcoded field name checks for None defaults +**REPLACE WITH:** Generic type-based handling or remove entirely (let interfaces handle) + +#### Change 3: `getRecordset()` (Lines 891-902) +**REMOVE:** Hardcoded field name checks for None defaults +**REPLACE WITH:** Generic type-based handling or remove entirely (let interfaces handle) + +--- + +### 2. **interfaceDbChatObjects.py** - ENHANCE Field Separation + +#### Change: `_separateObjectFields()` (Lines 210-254) +**CURRENT:** Lines 226-228 handle `documents`, `stats` as object fields +**CURRENT:** Lines 234 still has hardcoded `execParameters`, `expectedDocumentFormats`, `resultDocuments` + +**UPDATE TO:** +- Remove hardcoded field names from line 234 +- Rely purely on type detection (Dict, List) +- Handle relational fields (`documents`, `stats`, `logs`, `messages`) as object fields (not stored in main table) +- Handle JSONB fields (`execParameters`, `expectedDocumentFormats`) as simple fields (stored as JSONB) + +**Logic should be:** +```python +# Relational fields (stored in separate normalized tables) +if fieldName in ['documents', 'stats', 'logs', 'messages']: + objectFields[fieldName] = value + continue + +# JSONB fields (stored as JSONB in main table) +if (fieldType == dict or fieldType == list or + (hasattr(fieldType, '__origin__') and fieldType.__origin__ in (dict, list))): + simpleFields[fieldName] = value # Store as JSONB +``` + +--- + +### 3. **interfaceDbComponentObjects.py** - ADD Field Separation + +#### Add: `_separateObjectFields()` method +**LOCATION:** After `_initializeDatabase()` method (around line 140) + +**IMPLEMENTATION:** Similar to `interfaceDbChatObjects.py` but adapted for ComponentObjects models +- Check which models used by this interface have relational/JSONB fields +- Implement type-based field separation +- Use before calling `db.recordCreate()` and `db.recordModify()` + +**Models used:** `Prompt`, `FileItem`, `FileData`, `VoiceSettings`, `MessagingSubscription`, etc. +- Check if any have relational fields that need separate handling + +--- + +### 4. **interfaceDbAppObjects.py** - ADD Field Separation + +#### Add: `_separateObjectFields()` method +**LOCATION:** After `_initializeDatabase()` method (around line 135) + +**IMPLEMENTATION:** Similar to `interfaceDbChatObjects.py` but adapted for AppObjects models +- Check which models used by this interface have relational/JSONB fields +- Implement type-based field separation +- Use before calling `db.recordCreate()` and `db.recordModify()` + +**Models used:** `User`, `Mandate`, `UserInDB`, `Token`, `AuthEvent`, `UserConnection`, etc. +- Check if any have relational fields that need separate handling + +--- + +## Impact Analysis + +### Models Affected: + +1. **ChatWorkflow** (`datamodelChat.py`) + - Fields: `logs`, `messages`, `stats`, `tasks` (List types) + - Currently: Hardcoded in connector + - After: Handled by `interfaceDbChatObjects._separateObjectFields()` + +2. **ActionItem** (`datamodelChat.py`) + - Fields: `execParameters` (Dict), `expectedDocumentFormats` (List) + - Currently: Hardcoded in connector + - After: Handled by `interfaceDbChatObjects._separateObjectFields()` + +3. **AutomationDefinition** (`datamodelChat.py`) + - Contains `ActionItem` objects + - Currently: Hardcoded in connector + - After: Handled by `interfaceDbChatObjects._separateObjectFields()` + +--- + +## Migration Steps + +### Step 1: Update `interfaceDbChatObjects.py` +1. Enhance `_separateObjectFields()` to handle all JSONB fields based on type only +2. Remove hardcoded field names +3. Test with ChatWorkflow and AutomationDefinition models + +### Step 2: Add Field Separation to Other Interfaces +1. Add `_separateObjectFields()` to `interfaceDbComponentObjects.py` +2. Add `_separateObjectFields()` to `interfaceDbAppObjects.py` +3. Update all `recordCreate()` and `recordModify()` calls to use field separation +4. Update `interfaceRbac.py` to remove hardcoded field names from `getRecordsetWithRBAC()` + +### Step 3: Clean Up Connector +1. Remove hardcoded field names from `_get_model_fields()` +2. Remove hardcoded None defaults from `_loadTable()` and `getRecordset()` +3. Make connector purely type-based + +### Step 4: Testing +1. Test all CRUD operations for ChatWorkflow +2. Test all CRUD operations for AutomationDefinition +3. Test all CRUD operations for ComponentObjects models +4. Test all CRUD operations for AppObjects models +5. Verify JSONB fields are stored/loaded correctly + +--- + +## Summary of Changes Needed + +| File | Change Type | Description | +|------|-------------|-------------| +| `connectorDbPostgre.py` | **REMOVE** | Remove hardcoded field names from `_get_model_fields()` (lines 61-70) | +| `connectorDbPostgre.py` | **REMOVE** | Remove hardcoded None defaults from `_loadTable()` (lines 689-700) | +| `connectorDbPostgre.py` | **REMOVE** | Remove hardcoded None defaults from `getRecordset()` (lines 891-902) | +| `interfaceDbChatObjects.py` | **UPDATE** | Remove hardcoded fields from `_separateObjectFields()` line 234 | +| `interfaceDbChatObjects.py` | **UPDATE** | Add `logs`, `messages` to relational fields list (line 226) | +| `interfaceDbComponentObjects.py` | **ADD** | Add `_separateObjectFields()` method | +| `interfaceDbComponentObjects.py` | **UPDATE** | Use field separation before all `recordCreate()`/`recordModify()` calls | +| `interfaceDbAppObjects.py` | **ADD** | Add `_separateObjectFields()` method | +| `interfaceDbAppObjects.py` | **UPDATE** | Use field separation before all `recordCreate()`/`recordModify()` calls | +| `interfaceRbac.py` | **UPDATE** | Remove hardcoded field names from `getRecordsetWithRBAC()` (lines 115-118) | +| `interfaceRbac.py` | **UPDATE** | Use type-based None defaults instead of hardcoded field names | + +--- + +## Notes + +- The connector should be **completely generic** and only use type information from Pydantic models +- Interfaces should handle domain-specific logic (which fields are relational vs JSONB) +- The `_separateObjectFields()` pattern in `interfaceDbChatObjects.py` is the correct approach +- Other interfaces should follow the same pattern diff --git a/ui_nyla/feature-trustee/doc_trustee_feature_ui_specification.md b/ui_nyla/feature-trustee/doc_trustee_feature_ui_specification.md index eba70a8..e9961fb 100644 --- a/ui_nyla/feature-trustee/doc_trustee_feature_ui_specification.md +++ b/ui_nyla/feature-trustee/doc_trustee_feature_ui_specification.md @@ -8,6 +8,29 @@ Dieses Dokument beschreibt die **UI-Architektur und Frontend-Implementierung** f **UI-Demo**: Eine interaktive HTML-Demo zur Visualisierung der UI-Struktur findet sich in [`doc_trustee_feature_ui_demo.html`](./doc_trustee_feature_ui_demo.html). +### Backend-Status ✅ IMPLEMENTIERT + +Das Backend ist vollständig implementiert und bereit für die Frontend-Entwicklung: + +| Komponente | Datei | Status | +|------------|-------|--------| +| **Datenmodelle** | `gateway/modules/datamodels/datamodelTrustee.py` | ✅ | +| **Interface** | `gateway/modules/interfaces/interfaceDbTrusteeObjects.py` | ✅ | +| **API-Routes** | `gateway/modules/routes/routeDataTrustee.py` | ✅ | +| **RBAC-Regeln** | `gateway/modules/interfaces/interfaceBootstrap.py` | ✅ | +| **App-Registrierung** | `gateway/app.py` | ✅ | + +**API-Basis-URL**: `/api/trustee/` + +**Implementierte Entities**: +- `TrusteeOrganisation` - Trustee-Organisationen +- `TrusteeRole` - Feature-spezifische Rollen (userreport, admin, operate) +- `TrusteeAccess` - Benutzerzugriffe auf Organisationen (mit optionalem Contract) +- `TrusteeContract` - Kundenverträge +- `TrusteeDocument` - Dokumente/Belege +- `TrusteePosition` - Buchungspositionen +- `TrusteePositionDocument` - Verknüpfung Position-Dokument + **Frontend-Stack**: React 19, TypeScript, Vite **Pattern**: - **Logic-Hook** (`use*Logic.tsx`) - Handhabt Daten laden, CRUD, State-Management, Column/Action/Field-Konfigurationen @@ -1069,15 +1092,24 @@ export function useOrganisationenLogic() { let options = attr.options; // Wenn options eine String-Referenz ist, dynamisch laden + // Backend verwendet jetzt PascalCase-Referenzen: TrusteeOrganisation, TrusteeRole, etc. if (typeof attr.options === 'string' && attr.type === 'select') { - // Beispiel: "trustee.organisation" -> API-Call zu /api/trustee/organisations/ - if (attr.options.startsWith('trustee.')) { - const resource = attr.options.replace('trustee.', ''); + // Beispiel: "TrusteeOrganisation" -> API-Call zu /api/trustee/organisations + const optionMap: Record Promise> = { + 'TrusteeOrganisation': trusteeApi.getOrganisationen, + 'TrusteeRole': trusteeApi.getRollen, + 'TrusteeContract': trusteeApi.getContracts, + 'TrusteeDocument': trusteeApi.getDocuments, + 'TrusteePosition': trusteeApi.getPositions, + 'User': () => api.get('/api/users'), + }; + + if (optionMap[attr.options]) { try { - const response = await trusteeApi[`get${resource.charAt(0).toUpperCase() + resource.slice(1)}`](); + const response = await optionMap[attr.options](); options = (response.data.items || response.data).map((item: any) => ({ value: item.id, - label: item.label || item.name || item.id + label: item.label || item.name || item.fullName || item.id })); } catch (err) { console.error(`Error loading options for ${attr.options}:`, err); @@ -1204,60 +1236,63 @@ export function useOrganisationenLogic() { **Datei**: `src/api.ts` erweitern ```typescript -// Trustee API functions +// Trustee API functions - Implementiert in gateway/modules/routes/routeDataTrustee.py export const trusteeApi = { - // Organisationen - getOrganisationen: () => api.get('/api/trustee/organisations/'), + // Organisationen (TrusteeOrganisation) + getOrganisationen: (pagination?: PaginationParams) => api.get('/api/trustee/organisations', { params: { pagination: pagination ? JSON.stringify(pagination) : undefined } }), getOrganisation: (id: string) => api.get(`/api/trustee/organisations/${id}`), - createOrganisation: (data: any) => api.post('/api/trustee/organisations/', data), + createOrganisation: (data: any) => api.post('/api/trustee/organisations', data), updateOrganisation: (id: string, data: any) => api.put(`/api/trustee/organisations/${id}`, data), deleteOrganisation: (id: string) => api.delete(`/api/trustee/organisations/${id}`), - // Rollen - getRollen: () => api.get('/api/trustee/roles/'), + // Rollen (TrusteeRole) + getRollen: (pagination?: PaginationParams) => api.get('/api/trustee/roles', { params: { pagination: pagination ? JSON.stringify(pagination) : undefined } }), getRolle: (id: string) => api.get(`/api/trustee/roles/${id}`), - createRolle: (data: any) => api.post('/api/trustee/roles/', data), + createRolle: (data: any) => api.post('/api/trustee/roles', data), updateRolle: (id: string, data: any) => api.put(`/api/trustee/roles/${id}`, data), deleteRolle: (id: string) => api.delete(`/api/trustee/roles/${id}`), - // Access - getAccess: () => api.get('/api/trustee/access/'), + // Access (TrusteeAccess) + getAccess: (pagination?: PaginationParams) => api.get('/api/trustee/access', { params: { pagination: pagination ? JSON.stringify(pagination) : undefined } }), + getAccessById: (id: string) => api.get(`/api/trustee/access/${id}`), getAccessForOrganisation: (orgId: string) => api.get(`/api/trustee/access/organisation/${orgId}`), - createAccess: (data: any) => api.post('/api/trustee/access/', data), + getAccessForUser: (userId: string) => api.get(`/api/trustee/access/user/${userId}`), + createAccess: (data: any) => api.post('/api/trustee/access', data), updateAccess: (id: string, data: any) => api.put(`/api/trustee/access/${id}`, data), deleteAccess: (id: string) => api.delete(`/api/trustee/access/${id}`), - // Contracts - getContracts: () => api.get('/api/trustee/contracts/'), + // Contracts (TrusteeContract) + getContracts: (pagination?: PaginationParams) => api.get('/api/trustee/contracts', { params: { pagination: pagination ? JSON.stringify(pagination) : undefined } }), + getContract: (id: string) => api.get(`/api/trustee/contracts/${id}`), getContractsForOrganisation: (orgId: string) => api.get(`/api/trustee/contracts/organisation/${orgId}`), - createContract: (data: any) => api.post('/api/trustee/contracts/', data), + createContract: (data: any) => api.post('/api/trustee/contracts', data), updateContract: (id: string, data: any) => api.put(`/api/trustee/contracts/${id}`, data), deleteContract: (id: string) => api.delete(`/api/trustee/contracts/${id}`), - // Documents - getDocuments: () => api.get('/api/trustee/documents/'), + // Documents (TrusteeDocument) + getDocuments: (pagination?: PaginationParams) => api.get('/api/trustee/documents', { params: { pagination: pagination ? JSON.stringify(pagination) : undefined } }), getDocument: (id: string) => api.get(`/api/trustee/documents/${id}`), getDocumentData: (id: string) => api.get(`/api/trustee/documents/${id}/data`, { responseType: 'blob' }), getDocumentsForContract: (contractId: string) => api.get(`/api/trustee/documents/contract/${contractId}`), - getDocumentsForPosition: (positionId: string) => api.get(`/api/trustee/documents/position/${positionId}`), - createDocument: (data: FormData) => api.post('/api/trustee/documents/', data, { headers: { 'Content-Type': 'multipart/form-data' } }), + createDocument: (data: FormData) => api.post('/api/trustee/documents', data, { headers: { 'Content-Type': 'multipart/form-data' } }), updateDocument: (id: string, data: any) => api.put(`/api/trustee/documents/${id}`, data), deleteDocument: (id: string) => api.delete(`/api/trustee/documents/${id}`), - // Positions - getPositions: () => api.get('/api/trustee/positions/'), + // Positions (TrusteePosition) + getPositions: (pagination?: PaginationParams) => api.get('/api/trustee/positions', { params: { pagination: pagination ? JSON.stringify(pagination) : undefined } }), getPosition: (id: string) => api.get(`/api/trustee/positions/${id}`), getPositionsForContract: (contractId: string) => api.get(`/api/trustee/positions/contract/${contractId}`), - getPositionsForDocument: (documentId: string) => api.get(`/api/trustee/positions/document/${documentId}`), - createPosition: (data: any) => api.post('/api/trustee/positions/', data), + getPositionsForOrganisation: (orgId: string) => api.get(`/api/trustee/positions/organisation/${orgId}`), + createPosition: (data: any) => api.post('/api/trustee/positions', data), updatePosition: (id: string, data: any) => api.put(`/api/trustee/positions/${id}`, data), deletePosition: (id: string) => api.delete(`/api/trustee/positions/${id}`), - // Position-Documents - getPositionDocuments: () => api.get('/api/trustee/position-documents/'), - getPositionDocumentsForPosition: (positionId: string) => api.get(`/api/trustee/position-documents/position/${positionId}`), - getPositionDocumentsForDocument: (documentId: string) => api.get(`/api/trustee/position-documents/document/${documentId}`), - createPositionDocument: (data: any) => api.post('/api/trustee/position-documents/', data), + // Position-Documents (TrusteePositionDocument) + getPositionDocuments: (pagination?: PaginationParams) => api.get('/api/trustee/position-documents', { params: { pagination: pagination ? JSON.stringify(pagination) : undefined } }), + getPositionDocument: (id: string) => api.get(`/api/trustee/position-documents/${id}`), + getDocumentsForPosition: (positionId: string) => api.get(`/api/trustee/position-documents/position/${positionId}`), + getPositionsForDocument: (documentId: string) => api.get(`/api/trustee/position-documents/document/${documentId}`), + createPositionDocument: (data: any) => api.post('/api/trustee/position-documents', data), deletePositionDocument: (id: string) => api.delete(`/api/trustee/position-documents/${id}`), }; ```