Your IP : 216.73.217.79


Current Path : /var/git/cesa-v3/docs/specs/
Upload File :
Current File : /var/git/cesa-v3/docs/specs/member-portal-login-design.md

# Member portal login (V3) — design

## Data model

| Table | Entity | Notes |
|-------|--------|-------|
| `user_login` | `UserLogin` | `id`, `contact_id`, `username`, `password`, `salt` |

## Security

```
form_login → chain provider
  ├─ users (UserList)
  ├─ member_portal_users (UserLogin + firm validation)
  └─ echasl23_users (UserOld)
```

- **Hasher:** `MemberPortalPasswordHasher` — plain compare; `crypt(plain, salt)` when `salt` is non-empty.
- **Roles:** `MemberPortalUserService::resolveRoles()` → `ROLE_MEMBER_FIRM`, `ROLE_USER`, `ROLE_LOGGED_IN`, `ROLE_MY_DETAILS`, `IS_AUTHENTICATED_FULLY`.
- **Firm resolution:** `MemberContactRepository::findByContactIdWithMemberFirm()`.

## Services

| Class | Responsibility |
|-------|----------------|
| `MemberPortalUserService` | Firm context, roles, session flags |
| `MemberPortalPasswordHasher` | Legacy password verification |
| `MemberPortalUserProvider` | Load `UserLogin`; reject when firm missing |

## Portal integration

`MemberFirmTransformationPortalService::resolvePortalContact()` accepts `UserList` (email + mandated principal) or `UserLogin` (contact id + mandated principal).

## Database validation (cesa_main)

Verified against live schema:

| Table / column | Entity mapping | Notes |
|----------------|----------------|-------|
| `user_login.id` | `UserLogin.id` | int PK auto_increment |
| `user_login.contact_id` | `UserLogin.contactId` | Matches `contacts.ContactsID` and `membercontacts.ContactID` (2,729+ rows) |
| `user_login.username` | `UserLogin.username` | `varchar(12)` — entity length set to 12 |
| `user_login.password` | `UserLogin.password` | `varchar(50)` |
| `user_login.salt` | `UserLogin.salt` | `varchar(50)`, nullable |
| `membercontacts.ContactID` | `MemberContact.id` | Join key for portal firm resolution |
| `membercontacts.OfficeID` | `MemberContact.member_office` | Direct FK to `tblMemberFirmOffices` (not `officecontacts` junction) |
| `tblmemberfirms.MemberStatus` | `MemberFirm.member_status` | Values include `Member`, `Retired`; no `Testing` in DB (kept in code for legacy parity) |

Firm eligibility counts (username/password accounts with `contact_id > 0`): **1,652** can resolve a firm with status in `Member` / `Testing` / `Retired` via `membercontacts` → office → firm — same count as legacy `contacts` → `officecontacts` path. **522** accounts have no `membercontacts` row and cannot pass firm checks on either system.