diff --git a/docs/portail-df-decision-tree.md b/docs/portail-df-decision-tree.md new file mode 100644 index 0000000000..a19cadffbc --- /dev/null +++ b/docs/portail-df-decision-tree.md @@ -0,0 +1,554 @@ +# Decision Tree - Portail DF Rights Verification + +## 1. Account Creation + +Copy the code below into [Mermaid Live Editor](https://mermaid.live) to generate the diagram. + +```mermaid +flowchart TD + C1[/"Utilisateur soumet
email + password + establishmentId"/] + C2{Email est un
compte de test ?} + C3[/"❌ TestAccountError
(403)"/] + C4{"PROSPECT
existe en base ZLV ?"} + C5[/"❌ ProspectMissingError
(404)"/] + C6{"PROSPECT
valide ?
hasAccount=true
ET hasCommitment=true"} + C7[/"❌ ProspectInvalidError
(403)"/] + C8{"ÉTABLISSEMENT ZLV
existe en base ?"} + C9[/"❌ EstablishmentMissingError
(404)"/] + C10["Appel API Portail DF
ceremaService.consultUsers(email)
→ Liste de STRUCTURES"] + C11{"∃ STRUCTURE
avec SIREN = SIREN
établissement ZLV ?"} + C12[/"❌ ProspectInvalidError
(403)
'Structure inconnue'"/] + C13{"STRUCTURE.acces_lovac
> date du jour ?
(commitment valide)"} + C14[/"❌ ProspectInvalidError
(403)
'Pas de commitment LOVAC'"/] + C15["verifyAccessRights()
sur le GROUPE de la STRUCTURE"] + C16{"GROUPE.lovac = true
OU GROUPE.niveau_acces = 'lovac' ?"} + C17[/"❌ ProspectInvalidError
(403)
'niveau_acces_invalide'"/] + C18{"PÉRIMÈTRE du GROUPE
couvre AU MOINS 1 commune
de l'ÉTABLISSEMENT ZLV ?
(voir règle de couverture)"} + C19[/"❌ ProspectInvalidError
(403)
'perimetre_invalide'"/] + C20["✅ Compte USER créé
inséré en base ZLV"] + + C1 --> C2 + C2 -->|Oui| C3 + C2 -->|Non| C4 + C4 -->|Non| C5 + C4 -->|Oui| C6 + C6 -->|Non| C7 + C6 -->|Oui| C8 + C8 -->|Non| C9 + C8 -->|Oui| C10 + C10 --> C11 + C11 -->|Non| C12 + C11 -->|Oui| C13 + C13 -->|Non| C14 + C13 -->|Oui| C15 + C15 --> C16 + C16 -->|Non| C17 + C16 -->|Oui| C18 + C18 -->|Non| C19 + C18 -->|Oui| C20 + + style C3 fill:#ffcccc,stroke:#cc0000 + style C5 fill:#ffcccc,stroke:#cc0000 + style C7 fill:#ffcccc,stroke:#cc0000 + style C9 fill:#ffcccc,stroke:#cc0000 + style C12 fill:#ffcccc,stroke:#cc0000 + style C14 fill:#ffcccc,stroke:#cc0000 + style C17 fill:#ffcccc,stroke:#cc0000 + style C19 fill:#ffcccc,stroke:#cc0000 + style C20 fill:#ccffcc,stroke:#00cc00 +``` + +--- + +## 2. Login (single-establishment) + +```mermaid +flowchart TD + L1[/"Utilisateur soumet
email + password"/] + L2{"USER
existe en base ZLV ?"} + L3[/"❌ UserMissingError
(404)"/] + L4{"USER.deletedAt
!= null ?"} + L5[/"❌ UserDeletedError
(410)"/] + L6{Mot de passe
valide ?} + L7[/"❌ AuthenticationFailedError
(401)"/] + L8{"USER.role
= ADMIN ?"} + L9["Flux 2FA Admin
(si config.auth.admin2faEnabled)"] + L10["signInToEstablishment()
avec USER.establishmentId"] + L11{"ÉTABLISSEMENT ZLV
existe en base ?"} + L12[/"❌ EstablishmentMissingError
(404)"/] + L13["verifyAndUpdatePortailDFRights()"] + L14{"USER.role
= ADMIN ?"} + L15["Skip vérification
Portail DF"] + L16["Appel API Portail DF
ceremaService.consultUsers(email)
→ Liste de STRUCTURES"] + L17{"∃ STRUCTURE
avec SIREN = SIREN
établissement ZLV ?"} + L18[/"❌ ForbiddenError
(403)
'Aucune structure correspondant
au SIREN xxx'"/] + L19["storeUserPerimeter()
Sauvegarde dans table
user_perimeters"] + L20{"STRUCTURE.acces_lovac
> date du jour ?"} + L21["⚠️ USER.suspendedAt = now
USER.suspendedCause =
'droits structure expires'
Connexion OK + bandeau"] + L22["verifyAccessRights()
sur le GROUPE"] + L23{"GROUPE.lovac = true
OU niveau_acces = 'lovac' ?"} + L24["⚠️ USER.suspendedAt = now
USER.suspendedCause =
'niveau_acces_invalide'
Connexion OK + bandeau"] + L25{"PÉRIMÈTRE couvre
AU MOINS 1 commune
établissement ?"} + L26["⚠️ USER.suspendedAt = now
USER.suspendedCause =
'perimetre_invalide'
Connexion OK + bandeau"] + L27{"USER était suspendu
pour cause Portail DF ?"} + L28["Lever suspension
USER.suspendedAt = null
USER.suspendedCause = null"] + L29["✅ Connexion réussie
Token JWT généré"] + + L1 --> L2 + L2 -->|Non| L3 + L2 -->|Oui| L4 + L4 -->|Oui| L5 + L4 -->|Non| L6 + L6 -->|Non| L7 + L6 -->|Oui| L8 + L8 -->|Oui| L9 + L8 -->|Non| L10 + L9 --> L10 + L10 --> L11 + L11 -->|Non| L12 + L11 -->|Oui| L13 + L13 --> L14 + L14 -->|Oui| L15 + L15 --> L29 + L14 -->|Non| L16 + L16 --> L17 + L17 -->|Non| L18 + L17 -->|Oui| L19 + L19 --> L20 + L20 -->|Non| L21 + L21 --> L29 + L20 -->|Oui| L22 + L22 --> L23 + L23 -->|Non| L24 + L24 --> L29 + L23 -->|Oui| L25 + L25 -->|Non| L26 + L26 --> L29 + L25 -->|Oui| L27 + L27 -->|Oui| L28 + L28 --> L29 + L27 -->|Non| L29 + + style L3 fill:#ffcccc,stroke:#cc0000 + style L5 fill:#ffcccc,stroke:#cc0000 + style L7 fill:#ffcccc,stroke:#cc0000 + style L12 fill:#ffcccc,stroke:#cc0000 + style L18 fill:#ffcccc,stroke:#cc0000 + style L21 fill:#fff3cd,stroke:#ffc107 + style L24 fill:#fff3cd,stroke:#ffc107 + style L26 fill:#fff3cd,stroke:#ffc107 + style L29 fill:#ccffcc,stroke:#00cc00 +``` + +--- + +## 3. Establishment Switch (multi-establishment) + +```mermaid +flowchart TD + M1["USER connecté
avec établissement A"] + M2["Clic sur liste déroulante
des établissements"] + M3["API: GET /establishments
→ Liste établissements
où USER est membre"] + M4["Sélection établissement B"] + M5["API: POST /account/change-establishment
changeEstablishment()"] + M6["verifyAndUpdatePortailDFRights()
pour établissement B"] + M7{"∃ STRUCTURE Portail DF
avec SIREN = SIREN
établissement B ?"} + M8[/"❌ ForbiddenError
(403)
'Structure inconnue'"/] + M9["Vérifications Portail DF
(commitment, LOVAC, périmètre)
→ voir diagramme Connexion"] + M10["✅ Nouveau Token JWT
avec establishmentId = B"] + + M1 --> M2 + M2 --> M3 + M3 --> M4 + M4 --> M5 + M5 --> M6 + M6 --> M7 + M7 -->|Non| M8 + M7 -->|Oui| M9 + M9 --> M10 + + style M8 fill:#ffcccc,stroke:#cc0000 + style M10 fill:#ccffcc,stroke:#00cc00 +``` + +--- + +## Entity Glossary + +| Entity | Source | Description | +|--------|--------|-------------| +| **USER** | ZLV Database | ZLV application user | +| **PROSPECT** | ZLV Database | Pending account creation request | +| **ZLV ESTABLISHMENT** | ZLV Database | Local authority/EPCI with its geoCodes (INSEE commune codes) | +| **Portail DF STRUCTURE** | Portail DF API | Organization on Portail DF, identified by SIREN, has `acces_lovac` (date) | +| **Portail DF GROUP** | Portail DF API | Subset of a structure with `lovac` (bool), `niveau_acces`, and a perimeter | +| **PERIMETER** | Portail DF API | Geographic area: `comm[]`, `dep[]`, `reg[]`, `epci[]` (SIREN codes), `fr_entiere` (bool) | + +--- + +## ZLV ↔ Portail DF Mapping + +``` +ZLV ESTABLISHMENT +├── id: UUID +├── siren: "123456789" ←──────────────┐ +└── geoCodes: ["67482", "67043", ...] │ Match by SIREN + │ +Portail DF STRUCTURE ─────────────────┘ +├── siren: "123456789" +├── acces_lovac: "2025-12-31" (commitment expiration date) +└── GROUP(S) Portail DF + ├── lovac: true/false + ├── niveau_acces: "lovac" | "dvf" | ... + └── PERIMETER + ├── comm: ["67482", "67218", ...] (communes) + ├── dep: ["67", "68", ...] (departments) + ├── reg: ["44", ...] (regions) + ├── epci: ["200023414", ...] (EPCI SIREN codes) + └── fr_entiere: false (entire France) +``` + +--- + +## Perimeter Coverage Rule + +A commune in the establishment is **covered** by the perimeter if **AT LEAST ONE** of the following conditions is true: + +``` +isCommuneInPerimeter(communeCode, perimeter) = true if: +│ +├─ perimeter.fr_entiere = true +│ → Full France access, all communes covered +│ +├─ communeCode ∈ perimeter.comm +│ → Commune directly listed (e.g.: "67482") +│ +├─ getDepartment(communeCode) ∈ perimeter.dep +│ → Commune's department listed (e.g.: "67" for "67482") +│ +├─ getRegion(getDepartment(communeCode)) ∈ perimeter.reg +│ → Department's region listed (e.g.: "44" Grand Est) +│ +└─ establishmentSiren ∈ perimeter.epci (AND no geo restriction) + → EPCI SIREN matches establishment (e.g.: "200023414") + → Special case: if user has ONLY epci[] (no comm/dep/reg), + and EPCI matches establishment SIREN, full access is granted +``` + +**Perimeter validation**: The perimeter is valid if **AT LEAST ONE** commune of the establishment is covered: + +```javascript +// server/src/services/ceremaService/perimeterService.ts:181-183 +const hasValidPerimeter = establishmentGeoCodes.some((geoCode) => + isCommuneInPerimeter(geoCode, ceremaUser.perimeter!) +); +``` + +> ⚠️ **Important**: Only **one** covered commune is needed to validate the perimeter, not all of them! + +--- + +## Color Legend + +| Color | Meaning | +|-------|---------| +| 🟢 Green | Success (account created / login successful) | +| 🔴 Red | Blocking error (creation/login denied) | +| 🟡 Yellow | Warning (login allowed with banner) | + +--- + +## Portail DF Suspension Causes + +| Cause | Entity | Field Checked | Error Condition | +|-------|--------|---------------|-----------------| +| `droits structure expires` | STRUCTURE | `acces_lovac` | Date expired (< today) | +| `niveau_acces_invalide` | GROUP | `lovac` AND `niveau_acces` | `lovac=false` AND `niveau_acces≠'lovac'` | +| `perimetre_invalide` | GROUP.PERIMETER | `comm`, `dep`, `reg`, `fr_entiere` | No establishment commune covered | +| `droits utilisateur expires` | Portail DF USER | User expiration date | Date expired | +| `cgu vides` | Portail DF USER | CGU validated | CGU not validated | + +--- + +## Creation vs Login Differences + +| Verification | Entity.Field | Creation | Login | +|--------------|--------------|----------|-------| +| SIREN not found | STRUCTURE.siren | ❌ Blocked (403) | ❌ Blocked (403) | +| Commitment expired | STRUCTURE.acces_lovac | ❌ Blocked (403) | ⚠️ Suspended + banner | +| Invalid access level | GROUP.lovac/niveau_acces | ❌ Blocked (403) | ⚠️ Suspended + banner | +| Invalid perimeter | GROUP.PERIMETER | ❌ Blocked (403) | ⚠️ Suspended + banner | + +--- + +## Multi-Establishment Case + +A user can be a member of **multiple ZLV establishments**. Each establishment may correspond to a **different Portail DF STRUCTURE** (different SIREN). + +``` +ZLV USER +├── Member of Establishment A (SIREN: 111111111) +│ └── Verified against Portail DF STRUCTURE (SIREN: 111111111) +│ +└── Member of Establishment B (SIREN: 222222222) + └── Verified against Portail DF STRUCTURE (SIREN: 222222222) +``` + +**During establishment switch**: +1. User clicks on the dropdown +2. Selects another establishment +3. `changeEstablishment()` calls `verifyAndUpdatePortailDFRights()` for the **new establishment** +4. Verification looks for a STRUCTURE with the **new establishment's SIREN** +5. If found: rights verification (commitment, LOVAC, perimeter) +6. If not found: **Login denied** (403) + +--- + +## 4. Data Filtering by User Perimeter + +Data filtering is done at two levels: +1. **Establishment level**: ZLV establishment geoCodes +2. **User level**: User's Portail DF perimeter (intersection with establishment geoCodes) + +```mermaid +flowchart TD + subgraph AUTH["Authentification (middleware auth.ts)"] + U1["USER connecté
(token JWT)"] + U2["Charger USER, ESTABLISHMENT,
USER_PERIMETER"] + end + + subgraph COMPUTE["Calcul effectiveGeoCodes"] + C1{"USER_PERIMETER
existe ?"} + C2{"fr_entiere
= true ?"} + C3["effectiveGeoCodes =
establishment.geoCodes"] + C4["effectiveGeoCodes =
intersection(
establishment.geoCodes,
user_perimeter)"] + end + + subgraph FILTERS["Filtres appliqués aux requêtes"] + F1["🏠 HOUSING
(Parc logement)"] + F2["🗺️ LOCALITIES
(Carte/communes)"] + F3["📋 CAMPAIGNS
(Campagnes)"] + F4["📤 EXPORT
(Export Excel)"] + F5["📁 GROUPS
(Groupes)"] + end + + subgraph RULES["Règles de filtrage"] + R1["WHERE geo_code
IN (effectiveGeoCodes)"] + R2["WHERE geo_code
IN (effectiveGeoCodes)"] + R3["Masquer si ∃ housing
hors effectiveGeoCodes"] + R4["localities =
effectiveGeoCodes"] + R5["Masquer si ∃ housing
hors effectiveGeoCodes"] + end + + U1 --> U2 + U2 --> C1 + C1 -->|Non| C3 + C1 -->|Oui| C2 + C2 -->|Oui| C3 + C2 -->|Non| C4 + + C3 --> F1 + C4 --> F1 + F1 --> R1 + + C3 --> F2 + C4 --> F2 + F2 --> R2 + + C3 --> F4 + C4 --> F4 + F4 --> R4 + + C3 --> F3 + C4 --> F3 + F3 --> R3 + + C3 --> F5 + C4 --> F5 + F5 --> R5 + + style U1 fill:#e3f2fd,stroke:#1976d2 + style C3 fill:#e8f5e9,stroke:#388e3c + style C4 fill:#fff3e0,stroke:#f57c00 + style R1 fill:#fff3e0,stroke:#f57c00 + style R2 fill:#fff3e0,stroke:#f57c00 + style R3 fill:#fff3e0,stroke:#f57c00 + style R4 fill:#fff3e0,stroke:#f57c00 + style R5 fill:#fff3e0,stroke:#f57c00 +``` + +### Computing effectiveGeoCodes + +On every authenticated request, the `auth.ts` middleware computes `effectiveGeoCodes`: + +```typescript +// server/src/middlewares/auth.ts +request.effectiveGeoCodes = filterGeoCodesByPerimeter( + establishment.geoCodes, + userPerimeter, + establishment.siren // For EPCI perimeter check +); +``` + +The `filterGeoCodesByPerimeter()` function: +- If **no perimeter**: returns `undefined` (no restriction) +- If **fr_entiere = true**: returns `undefined` (no restriction) +- If **EPCI match** (perimeter.epci includes establishment SIREN AND no geo restriction): returns `undefined` (no restriction) +- Otherwise: returns the **intersection** of establishment geoCodes with user perimeter (may be empty array if 0% intersection) + +### EPCI Perimeter (Special Case) + +EPCI perimeters work differently from commune/department/region perimeters: + +``` +User with perimeter: { epci: ["200023414"], comm: [], dep: [], reg: [] } +Establishment SIREN: "200023414" + ↓ +EPCI SIREN matches! → effectiveGeoCodes = undefined (full access) +``` + +**Rule**: If the user's perimeter contains **only** EPCI SIREN codes (no comm/dep/reg), and the establishment SIREN is in the epci array, the user gets **full access** to all establishment geoCodes. + +### effectiveGeoCodes: `undefined` vs `[]` + +| Value | Meaning | Result | +|-------|---------|--------| +| `undefined` | No restriction | User sees **all** establishment housing | +| `[]` (empty array) | 0% intersection | User sees **nothing** | +| `['67482', '67043']` | Partial intersection | User sees only housing in those communes | + +> ⚠️ **Important**: An empty array `[]` is NOT the same as `undefined`. Empty means "no access", while undefined means "full access". + +### Filter Details by Entity + +| Entity | Table | Applied Filter | SQL Example | +|--------|-------|----------------|-------------| +| **HOUSING** | `housing` | `geo_code IN effectiveGeoCodes` | `WHERE geo_code IN ('67482', '67043')` | +| **LOCALITIES** | `localities` | `geo_code IN effectiveGeoCodes` | `WHERE geo_code IN ('67482', '67043')` | +| **CAMPAIGNS** | `campaigns` | Hide if any housing outside perimeter | `WHERE NOT EXISTS (SELECT 1 FROM campaigns_housing WHERE housing_geo_code NOT IN effectiveGeoCodes)` | +| **GROUPS** | `groups` | Hide if any housing outside perimeter | `WHERE NOT EXISTS (SELECT 1 FROM groups_housing WHERE housing_geo_code NOT IN effectiveGeoCodes)` | +| **OWNERS** | `owners` | Via HOUSING join | `JOIN housing ON ... WHERE geo_code IN (...)` | +| **EVENTS** | `events` | Via HOUSING or CAMPAIGN | Filtered via parent entity | +| **EXPORT** | - | `localities = effectiveGeoCodes` | Filter in the stream | + +> ⚠️ **Important**: Groups and campaigns are hidden if they contain **at least one** housing outside the user's perimeter. This ensures users only see groups/campaigns they have full access to. + +### Exceptions: Admins and Visitors + +Users with **ADMIN** or **VISITOR** role are **not filtered** by user perimeter. They see all establishment data (or all establishments for ADMIN). + +```typescript +// In housingController.ts, localityController.ts, etc. +const isAdminOrVisitor = [UserRole.ADMIN, UserRole.VISITOR].includes(role); +const filters = { + localities: isAdminOrVisitor + ? rawFilters.localities // No perimeter filtering + : effectiveGeoCodes // Perimeter filtering +}; +``` + +### Complete Filtering Chain + +``` +USER (token JWT) + │ + ▼ +MIDDLEWARE auth.ts + │ + ├── Load USER_PERIMETER from user_perimeters + │ + ├── Compute effectiveGeoCodes + │ = intersection(establishment.geoCodes, user_perimeter) + │ + ▼ +effectiveGeoCodes[] ─────────────────────────────────────────────────────────┐ + │ │ + │ ┌────────────────────┬────────────────────┬────────────────────┐ │ + │ ▼ ▼ ▼ ▼ │ + │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ + │ │ HOUSING │ │ LOCALITIES │ │ EXPORT │ │ DRAFTS │ │ + │ │ geo_code IN │ │ geo_code IN │ │ localities │ │ estab_id │ │ + │ │ effective │ │ effective │ │ = effective │ │ │ │ + │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ + │ │ + │ ┌────────────────────────────────────────┐ │ + │ ▼ ▼ │ + │ ┌─────────────────────────┐ ┌─────────────────────────┐ │ + │ │ CAMPAIGNS │ │ GROUPS │ │ + │ │ Hide if ∃ housing │ │ Hide if ∃ housing │ │ + │ │ NOT IN effectiveGeo │ │ NOT IN effectiveGeo │ │ + │ └─────────────────────────┘ └─────────────────────────┘ │ + │ │ +``` + +### User Perimeter Storage + +On **every login**, the user's Portail DF perimeter is stored/updated in the `user_perimeters` table via `refreshAuthorizedEstablishments()` in `accountController.ts`: + +```typescript +// server/src/controllers/accountController.ts +if (currentCeremaUser?.perimeter) { + const perimeter = currentCeremaUser.perimeter; + await userPerimeterRepository.upsert({ + userId: user.id, + geoCodes: perimeter.comm || [], + departments: perimeter.dep || [], + regions: perimeter.reg || [], + epci: perimeter.epci || [], // EPCI SIREN codes for EPCI-level perimeters + frEntiere: perimeter.fr_entiere || false, + updatedAt: new Date().toJSON() + }); +} +``` + +``` +TABLE user_perimeters +├── user_id: UUID (FK → users.id) +├── geo_codes: text[] (INSEE commune codes) +├── departments: text[] (department codes) +├── regions: text[] (region codes) +├── epci: text[] (EPCI SIREN codes - 9 chars) +├── fr_entiere: boolean +├── updated_at: timestamp +└── GIN INDEX on geo_codes, departments, regions +``` + +### Files Implementing Filtering + +| File | Role | +|------|------| +| `server/src/controllers/accountController.ts` | Save user perimeter from Portail DF on login | +| `server/src/repositories/userPerimeterRepository.ts` | CRUD operations for `user_perimeters` table | +| `server/src/middlewares/auth.ts` | Compute `effectiveGeoCodes` | +| `server/src/models/UserPerimeterApi.ts` | `filterGeoCodesByPerimeter()` function | +| `server/src/controllers/housingController.ts` | HOUSING filtering by perimeter | +| `server/src/controllers/localityController.ts` | LOCALITIES (map) filtering by perimeter | +| `server/src/controllers/housingExportController.ts` | EXPORT filtering by perimeter | +| `server/src/controllers/campaignController.ts` | CAMPAIGNS filtering by perimeter | +| `server/src/controllers/groupController.ts` | GROUPS filtering by perimeter | +| `server/src/repositories/localityRepository.ts` | `geoCodes` filter support | +| `server/src/repositories/campaignRepository.ts` | `geoCodes` filter support (hide if any housing outside) | +| `server/src/repositories/groupRepository.ts` | `geoCodes` filter support (hide if any housing outside) | + +--- + +## Source Files + +| File | Role | +|------|------| +| `server/src/controllers/userController.ts` | Account creation | +| `server/src/controllers/accountController.ts` | Login, establishment switch | +| `server/src/services/ceremaService/perimeterService.ts` | Rights verification, coverage rule | +| `server/src/services/ceremaService/ceremaService.ts` | Portail DF API call | +| `frontend/src/components/modals/SuspendedUserModal/SuspendedUserModal.tsx` | Suspension banner | + +--- + +## PDF Export + +For each diagram: +1. Copy the Mermaid code +2. Go to [https://mermaid.live](https://mermaid.live) +3. Paste the code in the editor +4. Click "Actions" → "Export as PNG" or "Export as SVG" +5. Convert to PDF if needed diff --git a/frontend/src/components/Header/SmallHeader.tsx b/frontend/src/components/Header/SmallHeader.tsx index 93e8a45655..25c121e7fa 100644 --- a/frontend/src/components/Header/SmallHeader.tsx +++ b/frontend/src/components/Header/SmallHeader.tsx @@ -15,11 +15,7 @@ import logo from '../../assets/images/zlv.svg'; import { useFilters } from '../../hooks/useFilters'; import { useAppDispatch } from '../../hooks/useStore'; import { useUser } from '../../hooks/useUser'; -import { - type Establishment, - fromEstablishmentDTO, - toEstablishmentDTO -} from '../../models/Establishment'; +import { type Establishment } from '../../models/Establishment'; import { getUserNavItem, UserNavItems } from '../../models/UserNavItem'; import { zlvApi } from '../../services/api.service'; import { changeEstablishment } from '../../store/actions/authenticationAction'; @@ -29,7 +25,7 @@ import styles from './small-header.module.scss'; function SmallHeader() { const dispatch = useAppDispatch(); const location = useLocation(); - const { establishment, isAdmin, isVisitor, isAuthenticated } = useUser(); + const { establishment, isAdmin, isVisitor, isAuthenticated, canChangeEstablishment, authorizedEstablishments } = useUser(); function getMainNavigationItem( navItem: UserNavItems @@ -119,20 +115,32 @@ function SmallHeader() { /> {isAuthenticated ? ( - isAdmin || isVisitor ? ( + canChangeEstablishment ? ( establishment ? ( - { - if (establishment) { - onChangeEstablishment( - fromEstablishmentDTO(establishment) - ); - } - }} - /> + isAdmin || isVisitor ? ( + { + if (establishment) { + onChangeEstablishment(establishment); + } + }} + /> + ) : ( + { + if (establishment) { + onChangeEstablishment(establishment); + } + }} + /> + ) ) : null ) : ( = Pick< - AutocompleteProps, + AutocompleteProps, 'disableClearable' | 'multiple' > & { className?: string; label?: ReactNode; - value: AutocompleteValue; + /** Pre-defined options to use instead of API search. When provided, no API call is made. */ + options?: ReadonlyArray; + value: AutocompleteValue; onChange( establishment: AutocompleteValue< - EstablishmentDTO, + Establishment, Multiple, DisableClearable, false @@ -33,10 +35,17 @@ function EstablishmentSearchableSelect< const [findEstablishments, { data: establishments, isFetching }] = useLazyFindEstablishmentsQuery(); - const options = (establishments ?? - []) as unknown as ReadonlyArray; + // Use pre-defined options if provided, otherwise use API search results + const hasPreDefinedOptions = props.options !== undefined; + const options: ReadonlyArray = hasPreDefinedOptions + ? (props.options ?? []) + : (establishments ?? []); async function search(query: string | undefined): Promise { + // Skip API search if we have pre-defined options + if (hasPreDefinedOptions) { + return; + } if (query) { await findEstablishments({ query }).unwrap(); } @@ -46,10 +55,10 @@ function EstablishmentSearchableSelect< option.id} getOptionLabel={(option) => option.name} diff --git a/frontend/src/components/modals/SuspendedUserModal/SuspendedUserModal.test.tsx b/frontend/src/components/modals/SuspendedUserModal/SuspendedUserModal.test.tsx index f0bc5f8db1..8ab8dbaf14 100644 --- a/frontend/src/components/modals/SuspendedUserModal/SuspendedUserModal.test.tsx +++ b/frontend/src/components/modals/SuspendedUserModal/SuspendedUserModal.test.tsx @@ -83,7 +83,25 @@ describe('SuspendedUserModal', () => { it('should render when user is suspended with multiple reasons', () => { renderWithUser('2025-01-01T00:00:00Z', 'droits utilisateur expires, droits structure expires, cgu vides'); expect(screen.getByText(/Vos droits d.accès à Zéro Logement Vacant ne sont plus valides/i)).toBeInTheDocument(); - expect(screen.getByText(/La date d.expiration de vos droits d.accès aux données LOVAC en tant qu.utilisateur ou ceux de votre structure a été dépassée/i)).toBeInTheDocument(); + expect(screen.getByText(/Plusieurs problèmes ont été détectés/i)).toBeInTheDocument(); + }); + + it('should render when user is suspended with invalid access level', () => { + renderWithUser('2025-01-01T00:00:00Z', 'niveau_acces_invalide'); + expect(screen.getByText(/Vos droits d.accès à Zéro Logement Vacant ne sont plus valides/i)).toBeInTheDocument(); + expect(screen.getByText(/Votre niveau d.accès aux données LOVAC sur le portail Données Foncières du Cerema n.est pas valide/i)).toBeInTheDocument(); + }); + + it('should render when user is suspended with invalid perimeter', () => { + renderWithUser('2025-01-01T00:00:00Z', 'perimetre_invalide'); + expect(screen.getByText(/Vos droits d.accès à Zéro Logement Vacant ne sont plus valides/i)).toBeInTheDocument(); + expect(screen.getByText(/Votre périmètre géographique sur le portail Données Foncières du Cerema ne correspond pas à votre établissement/i)).toBeInTheDocument(); + }); + + it('should render when user is suspended with both invalid access level and perimeter', () => { + renderWithUser('2025-01-01T00:00:00Z', 'niveau_acces_invalide, perimetre_invalide'); + expect(screen.getByText(/Vos droits d.accès à Zéro Logement Vacant ne sont plus valides/i)).toBeInTheDocument(); + expect(screen.getByText(/Plusieurs problèmes ont été détectés/i)).toBeInTheDocument(); }); it('should have a link to Portail des Données Foncières', () => { diff --git a/frontend/src/components/modals/SuspendedUserModal/SuspendedUserModal.tsx b/frontend/src/components/modals/SuspendedUserModal/SuspendedUserModal.tsx index 187bd3655c..5d44c5d58e 100644 --- a/frontend/src/components/modals/SuspendedUserModal/SuspendedUserModal.tsx +++ b/frontend/src/components/modals/SuspendedUserModal/SuspendedUserModal.tsx @@ -35,10 +35,18 @@ function SuspendedUserModal() { return user?.user.suspendedCause?.includes('cgu vides'); }, [user?.user.suspendedCause]); + const isAccessLevelInvalid = useMemo(() => { + return user?.user.suspendedCause?.includes('niveau_acces_invalide'); + }, [user?.user.suspendedCause]); + + const isPerimeterInvalid = useMemo(() => { + return user?.user.suspendedCause?.includes('perimetre_invalide'); + }, [user?.user.suspendedCause]); + const hasMultipleReasons = useMemo(() => { - const causes = [isCguEmpty, isUserExpired, isEstablishmentExpired].filter(Boolean); + const causes = [isCguEmpty, isUserExpired, isEstablishmentExpired, isAccessLevelInvalid, isPerimeterInvalid].filter(Boolean); return causes.length > 1; - }, [isCguEmpty, isUserExpired, isEstablishmentExpired]); + }, [isCguEmpty, isUserExpired, isEstablishmentExpired, isAccessLevelInvalid, isPerimeterInvalid]); useEffect(() => { if (ready && isSuspended) { @@ -70,12 +78,20 @@ function SuspendedUserModal() { {hasMultipleReasons ? ( <> - La date d'expiration de vos droits d'accès aux données LOVAC en tant qu'utilisateur ou ceux de votre structure a été dépassée. + Plusieurs problèmes ont été détectés avec vos droits d'accès aux données LOVAC. + + ) : isAccessLevelInvalid ? ( + <> + Votre niveau d'accès aux données LOVAC sur le portail Données Foncières du Cerema n'est pas valide. + + ) : isPerimeterInvalid ? ( + <> + Votre périmètre géographique sur le portail Données Foncières du Cerema ne correspond pas à votre établissement. ) : isCguEmpty ? ( <> @@ -85,10 +101,14 @@ function SuspendedUserModal() { <> La date d'expiration de vos droits d'accès aux données LOVAC en tant qu'utilisateur a été dépassée. - ) : ( + ) : isEstablishmentExpired ? ( <> La date d'expiration des droits d'accès aux données LOVAC de votre structure a été dépassée. + ) : ( + <> + Vos droits d'accès aux données LOVAC ne sont plus valides. + )} } @@ -99,28 +119,46 @@ function SuspendedUserModal() { {hasMultipleReasons ? ( <> - Rendez-vous sur le portail Données Foncières du Cerema pour vérifier vos droits d’accès aux données LOVAC et ceux de votre structure. + Rendez-vous sur le portail Données Foncières du Cerema pour vérifier vos droits d'accès aux données LOVAC et ceux de votre structure.
- Si vous n’avez pas de compte sur le portail Données Foncières du Cerema, vous devez en créer un. + Si vous n'avez pas de compte sur le portail Données Foncières du Cerema, vous devez en créer un. + + ) : isAccessLevelInvalid ? ( + <> + Rendez-vous sur le portail Données Foncières du Cerema pour vérifier que votre groupe dispose bien d'un accès aux données LOVAC. +
+ Si vous ne pouvez pas modifier votre groupe vous-même, demandez au(x) gestionnaire(s) de votre structure de le faire. + + ) : isPerimeterInvalid ? ( + <> + Rendez-vous sur le portail Données Foncières du Cerema pour vérifier que votre périmètre géographique correspond bien à votre établissement. +
+ Si vous ne pouvez pas modifier votre périmètre vous-même, demandez au(x) gestionnaire(s) de votre structure de le faire. ) : isCguEmpty ? ( <> - Rendez-vous sur le portail Données Foncières du Cerema pour valider les conditions générales d’utilisation. + Rendez-vous sur le portail Données Foncières du Cerema pour valider les conditions générales d'utilisation.
- Si vous n’avez pas de compte sur le portail Données Foncières du Cerema, vous devez en créer un. + Si vous n'avez pas de compte sur le portail Données Foncières du Cerema, vous devez en créer un. ) : isUserExpired ? ( <> - Rendez-vous sur le portail Données Foncières du Cerema pour modifier la date d’expiration de vos droits d’accès aux données. + Rendez-vous sur le portail Données Foncières du Cerema pour modifier la date d'expiration de vos droits d'accès aux données.
Si vous ne pouvez pas modifier la date vous-même, demandez au(x) gestionnaire(s) de votre structure de le faire. - ) : ( + ) : isEstablishmentExpired ? ( <> - Rendez-vous sur le portail Données Foncières du Cerema pour renouveler votre demande d’accès aux données LOVAC. + Rendez-vous sur le portail Données Foncières du Cerema pour renouveler votre demande d'accès aux données LOVAC.
Si vous ne pouvez pas renouveler la demande vous-même, demandez au(x) gestionnaire(s) de votre structure de le faire. + ) : ( + <> + Rendez-vous sur le portail Données Foncières du Cerema pour vérifier vos droits d'accès. +
+ Si vous n'avez pas de compte sur le portail Données Foncières du Cerema, vous devez en créer un. + )}
diff --git a/frontend/src/hooks/useUser.tsx b/frontend/src/hooks/useUser.tsx index 424e581e52..ec923b00c0 100644 --- a/frontend/src/hooks/useUser.tsx +++ b/frontend/src/hooks/useUser.tsx @@ -1,6 +1,7 @@ import { UserRole } from '@zerologementvacant/models'; import { useAppDispatch, useAppSelector } from './useStore'; import authenticationSlice from '~/store/reducers/authenticationReducer'; +import { zlvApi } from '~/services/api.service'; export function useUser() { const dispatch = useAppDispatch(); @@ -8,6 +9,7 @@ export function useUser() { const { data, error, isError, isLoading, isUninitialized, isSuccess } = logIn; const establishment = data?.establishment; const user = data?.user; + const authorizedEstablishments = data?.authorizedEstablishments; const isAuthenticated = !!data?.accessToken && !!data?.user && !!data?.establishment; @@ -17,6 +19,10 @@ export function useUser() { const isUsual = isAuthenticated && user?.role === UserRole.USUAL; const isVisitor = isAuthenticated && user?.role === UserRole.VISITOR; + // USUAL users with multiple authorized establishments can change establishment + const hasMultipleEstablishments = (authorizedEstablishments?.length ?? 0) > 1; + const canChangeEstablishment = isAdmin || isVisitor || (isUsual && hasMultipleEstablishments); + function displayName(): string { if (user?.firstName && user?.lastName) { return `${user.firstName} ${user.lastName}`; @@ -30,6 +36,8 @@ export function useUser() { } function logOut() { + // Reset RTK Query cache to clear all cached data from previous user + dispatch(zlvApi.util.resetApiState()); dispatch(authenticationSlice.actions.logOut()); } @@ -37,16 +45,18 @@ export function useUser() { displayName, logOut, establishment, + authorizedEstablishments, user, isAdmin, isAuthenticated, isGuest, isUsual, isVisitor, + canChangeEstablishment, error, isError, isLoading, isUninitialized, isSuccess }; -}; +} diff --git a/frontend/src/mocks/handlers/auth-handlers.ts b/frontend/src/mocks/handlers/auth-handlers.ts index 94b3396dcd..bdca676805 100644 --- a/frontend/src/mocks/handlers/auth-handlers.ts +++ b/frontend/src/mocks/handlers/auth-handlers.ts @@ -1,7 +1,7 @@ import { http, HttpResponse, RequestHandler } from 'msw'; import { constants } from 'node:http2'; -import type { UserDTO } from '@zerologementvacant/models'; +import type { EstablishmentDTO, UserDTO } from '@zerologementvacant/models'; import config from '../../utils/config'; import data from './data'; @@ -14,6 +14,7 @@ interface AuthPayload { interface Auth { user: UserDTO; accessToken: string; + establishment: EstablishmentDTO; } export const authHandlers: RequestHandler[] = [ @@ -28,9 +29,24 @@ export const authHandlers: RequestHandler[] = [ }); } + // Find establishment for the user + const establishment: EstablishmentDTO = data.establishments.find( + (e) => e.id === user.establishmentId + ) ?? { + id: user.establishmentId ?? 'test-establishment-id', + name: 'Test Establishment', + shortName: 'Test', + siren: '123456789', + geoCodes: ['75056'], + available: true, + kind: 'CA', + source: 'seed' + }; + return HttpResponse.json({ user, - accessToken: 'fake-access-token' + accessToken: 'fake-access-token', + establishment }); } ) diff --git a/frontend/src/models/User.tsx b/frontend/src/models/User.tsx index 2461bf5732..886544a198 100644 --- a/frontend/src/models/User.tsx +++ b/frontend/src/models/User.tsx @@ -12,6 +12,8 @@ export interface AuthUser { user: User; accessToken: string; establishment: Establishment; + /** List of establishments the user has access to (for multi-structure USUAL users) */ + authorizedEstablishments?: Establishment[]; } export interface User extends Omit { diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts index 9adbdc4718..afd8c82929 100644 --- a/frontend/src/services/auth.service.ts +++ b/frontend/src/services/auth.service.ts @@ -1,11 +1,30 @@ +import type { EstablishmentDTO } from '@zerologementvacant/models'; +import { fromEstablishmentDTO } from '../models/Establishment'; +import type { AuthUser, User } from '../models/User'; import config from '../utils/config'; -import type { AuthUser } from '../models/User'; interface TwoFactorResponse { requiresTwoFactor: true; email: string; } +// Raw API response before transformation +interface AuthUserRaw { + user: User; + accessToken: string; + establishment: EstablishmentDTO; + authorizedEstablishments?: EstablishmentDTO[]; +} + +function transformAuthUser(raw: AuthUserRaw): AuthUser { + return { + user: raw.user, + accessToken: raw.accessToken, + establishment: fromEstablishmentDTO(raw.establishment), + authorizedEstablishments: raw.authorizedEstablishments?.map(fromEstablishmentDTO) + }; +} + type LoginResponse = AuthUser | TwoFactorResponse; const login = async ( @@ -19,7 +38,13 @@ const login = async ( body: JSON.stringify({ email, password, establishmentId }) }).then((response) => { if (response.ok) { - return response.json(); + return response.json().then((data) => { + // Check if 2FA is required + if ('requiresTwoFactor' in data) { + return data as TwoFactorResponse; + } + return transformAuthUser(data as AuthUserRaw); + }); } else { throw new Error('Authentication failed'); } @@ -37,7 +62,7 @@ const verifyTwoFactor = async ( body: JSON.stringify({ email, code, establishmentId }) }).then((response) => { if (response.ok) { - return response.json(); + return response.json().then((data) => transformAuthUser(data as AuthUserRaw)); } else { throw new Error('2FA verification failed'); } @@ -62,7 +87,7 @@ const resetPassword = async (key: string, password: string) => { } }; -const changeEstablishment = async (establishmentId: string) => { +const changeEstablishment = async (establishmentId: string): Promise => { return fetch( `${config.apiEndpoint}/api/account/establishments/${establishmentId}`, { @@ -74,7 +99,7 @@ const changeEstablishment = async (establishmentId: string) => { } ).then((response) => { if (response.ok) { - return response.json(); + return response.json().then((data) => transformAuthUser(data as AuthUserRaw)); } else { throw new Error('Authentication failed'); } diff --git a/frontend/src/views/Login/LoginView.tsx b/frontend/src/views/Login/LoginView.tsx index 0e5121ba14..3a6e6d09f2 100644 --- a/frontend/src/views/Login/LoginView.tsx +++ b/frontend/src/views/Login/LoginView.tsx @@ -6,13 +6,13 @@ import Box from '@mui/material/Box'; import Container from '@mui/material/Container'; import Grid from '@mui/material/Grid'; import Typography from '@mui/material/Typography'; -import type { EstablishmentDTO } from '@zerologementvacant/models'; import { useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { useLocation, useNavigate } from 'react-router-dom'; import * as yup from 'yup'; import EstablishmentSearchableSelect from '~/components/establishment/EstablishmentSearchableSelect'; +import { type Establishment } from '../../models/Establishment'; import building from '../../assets/images/building.svg'; import AppLink from '../../components/_app/AppLink/AppLink'; import AppTextInputNext from '../../components/_app/AppTextInput/AppTextInputNext'; @@ -57,7 +57,7 @@ const LoginView = () => { const navigate = useNavigate(); const auth = useAppSelector((state) => state.authentication); - const [establishment, setEstablishment] = useState( + const [establishment, setEstablishment] = useState( null ); diff --git a/packages/models/src/UserDTO.ts b/packages/models/src/UserDTO.ts index 0ced638cb8..13b5e73f53 100644 --- a/packages/models/src/UserDTO.ts +++ b/packages/models/src/UserDTO.ts @@ -17,6 +17,15 @@ export type SuspendedCause = (typeof SUSPENDED_CAUSE_VALUES)[number]; */ export type SuspendedCauseField = string | null; +/** + * Represents an establishment that a user has access to via Portail DF. + */ +export interface UserEstablishment { + establishmentId: string; + establishmentSiren: string; + hasCommitment: boolean; +} + export interface UserDTO { id: string; email: string; @@ -25,7 +34,10 @@ export interface UserDTO { phone: string | null; position: string | null; timePerWeek: TimePerWeek | null; + /** Current/primary establishment ID */ establishmentId: string | null; + /** List of all establishments the user has access to (multi-structure support) */ + authorizedEstablishments?: UserEstablishment[]; role: UserRole; activatedAt: string; lastAuthenticatedAt: string | null; diff --git a/server/src/controllers/accountController.test.ts b/server/src/controllers/accountController.test.ts index 3b823a7e30..aa498ae76f 100644 --- a/server/src/controllers/accountController.test.ts +++ b/server/src/controllers/accountController.test.ts @@ -166,6 +166,50 @@ describe('Account controller', () => { accessToken: expect.any(String) }); }); + + it('should allow login for suspended user (modal will be displayed on frontend)', async () => { + const suspendedUser: UserApi = { + ...genUserApi(establishment.id), + password: bcrypt.hashSync('TestPassword123!', SALT_LENGTH), + suspendedAt: new Date().toJSON(), + suspendedCause: 'droits utilisateur expires' + }; + await Users().insert(formatUserApi(suspendedUser)); + + const { body, status } = await request(url).post(testRoute).send({ + email: suspendedUser.email, + password: 'TestPassword123!' + }); + + // Suspended users can login - the frontend will display the suspension modal + expect(status).toBe(constants.HTTP_STATUS_OK); + expect(body).toMatchObject({ + establishment, + accessToken: expect.any(String) + }); + + // Cleanup + await Users().where('id', suspendedUser.id).delete(); + }); + + it('should fail if the user is deleted', async () => { + const deletedUser: UserApi = { + ...genUserApi(establishment.id), + password: bcrypt.hashSync('TestPassword123!', SALT_LENGTH), + deletedAt: new Date().toJSON() + }; + await Users().insert(formatUserApi(deletedUser)); + + const { status } = await request(url).post(testRoute).send({ + email: deletedUser.email, + password: 'TestPassword123!' + }); + + expect(status).toBe(constants.HTTP_STATUS_FORBIDDEN); + + // Cleanup + await Users().where('id', deletedUser.id).delete(); + }); }); describe('Verify 2FA', () => { diff --git a/server/src/controllers/accountController.ts b/server/src/controllers/accountController.ts index cb17d8d56e..23d2619411 100644 --- a/server/src/controllers/accountController.ts +++ b/server/src/controllers/accountController.ts @@ -10,6 +10,7 @@ import EstablishmentMissingError from '~/errors/establishmentMissingError'; import ResetLinkExpiredError from '~/errors/resetLinkExpiredError'; import ResetLinkMissingError from '~/errors/resetLinkMissingError'; import UnprocessableEntityError from '~/errors/unprocessableEntityError'; +import UserDeletedError from '~/errors/userDeletedError'; import UserMissingError from '~/errors/userMissingError'; import config from '~/infra/config'; import { logger } from '~/infra/logger'; @@ -24,6 +25,12 @@ import { import establishmentRepository from '~/repositories/establishmentRepository'; import resetLinkRepository from '~/repositories/resetLinkRepository'; import userRepository from '~/repositories/userRepository'; +import ceremaService from '~/services/ceremaService'; +import { + verifyAccessRights, + accessErrorsToSuspensionCause +} from '~/services/ceremaService/perimeterService'; +import userPerimeterRepository from '~/repositories/userPerimeterRepository'; import mailService from '~/services/mailService'; import { generateSimpleCode, @@ -49,19 +56,230 @@ const signInValidators = { body: signInSchema }; +/** + * Refresh authorized establishments for a user from Portail DF. + * This is called at login to keep the users_establishments table in sync + * with current Portail DF rights. + * + * Also verifies access rights (LOVAC access level + geographic perimeter) + * and suspends user if rights are no longer valid. + */ +async function refreshAuthorizedEstablishments(user: UserApi): Promise { + try { + // Fetch current rights from Portail DF + const ceremaUsers = await ceremaService.consultUsers(user.email); + + if (ceremaUsers.length === 0) { + logger.info('No Portail DF rights found for user at login', { + userId: user.id, + email: user.email + }); + return; + } + + // Filter users with valid LOVAC commitment + const ceremaUsersWithCommitment = ceremaUsers.filter((cu) => cu.hasCommitment); + const establishmentSirens = ceremaUsersWithCommitment.map((cu) => cu.establishmentSiren); + + // Find all known establishments matching the SIRENs + const knownEstablishments = await establishmentRepository.find({ + filters: { siren: establishmentSirens } + }); + + // Build authorized establishments list with access rights verification + const authorizedEstablishments: Array<{ + establishmentId: string; + establishmentSiren: string; + hasCommitment: boolean; + }> = []; + + const accessErrors: string[] = []; + + for (const est of knownEstablishments) { + const ceremaUser = ceremaUsersWithCommitment.find( + (cu) => cu.establishmentSiren === est.siren || cu.establishmentSiren === '*' + ); + + if (ceremaUser) { + // Verify access rights for this establishment (pass SIREN for EPCI perimeter check) + const accessRights = verifyAccessRights(ceremaUser, est.geoCodes, est.siren); + + if (accessRights.isValid) { + authorizedEstablishments.push({ + establishmentId: est.id, + establishmentSiren: est.siren, + hasCommitment: ceremaUser.hasCommitment + }); + } else { + logger.warn('Access rights verification failed for establishment at login', { + userId: user.id, + email: user.email, + establishmentId: est.id, + establishmentSiren: est.siren, + errors: accessRights.errors + }); + accessErrors.push(...accessRights.errors); + } + } + } + + // Check if user's current establishment lost access rights + if (user.establishmentId) { + const currentEstablishmentStillValid = authorizedEstablishments.some( + (e) => e.establishmentId === user.establishmentId + ); + + if (!currentEstablishmentStillValid && accessErrors.length > 0) { + // Suspend user if their current establishment lost access + const suspensionCause = accessErrorsToSuspensionCause( + [...new Set(accessErrors)] as any + ); + + logger.warn('Suspending user at login due to lost access rights', { + userId: user.id, + email: user.email, + establishmentId: user.establishmentId, + suspensionCause + }); + + // Re-fetch user to get latest lastAuthenticatedAt (updated by signIn) + const currentUser = await userRepository.get(user.id); + if (currentUser) { + await userRepository.update({ + ...currentUser, + suspendedAt: new Date().toJSON(), + suspendedCause: suspensionCause + }); + } + } + } + + // Get current authorized establishments for comparison + const currentAuthorized = await userRepository.getAuthorizedEstablishments(user.id); + const currentIds = new Set(currentAuthorized.map((e) => e.establishmentId)); + const newIds = new Set(authorizedEstablishments.map((e) => e.establishmentId)); + + // Check if there are changes + const hasChanges = + currentIds.size !== newIds.size || + [...currentIds].some((id) => !newIds.has(id)) || + [...newIds].some((id) => !currentIds.has(id)); + + if (hasChanges) { + logger.info('Updating authorized establishments for user at login', { + userId: user.id, + email: user.email, + previousCount: currentAuthorized.length, + newCount: authorizedEstablishments.length, + previousIds: [...currentIds], + newIds: [...newIds] + }); + + // Update authorized establishments + await userRepository.setAuthorizedEstablishments(user.id, authorizedEstablishments); + + // Log multi-structure status + const isMultiStructure = authorizedEstablishments.filter((e) => e.hasCommitment).length > 1; + if (isMultiStructure) { + logger.info('User identified as multi-structure at login', { + userId: user.id, + email: user.email, + authorizedEstablishmentsCount: authorizedEstablishments.length + }); + } + } else { + logger.debug('No changes to authorized establishments for user', { + userId: user.id, + email: user.email + }); + } + + // Save user perimeter from Portail DF for filtering + // Use the perimeter from the user's current establishment + if (user.establishmentId) { + const currentEstablishment = knownEstablishments.find( + (est) => est.id === user.establishmentId + ); + if (currentEstablishment) { + const currentCeremaUser = ceremaUsersWithCommitment.find( + (cu) => + cu.establishmentSiren === currentEstablishment.siren || + cu.establishmentSiren === '*' + ); + + if (currentCeremaUser?.perimeter) { + const perimeter = currentCeremaUser.perimeter; + await userPerimeterRepository.upsert({ + userId: user.id, + geoCodes: perimeter.comm || [], + departments: perimeter.dep || [], + regions: perimeter.reg || [], + epci: perimeter.epci || [], + frEntiere: perimeter.fr_entiere || false, + updatedAt: new Date().toJSON() + }); + + logger.info('User perimeter saved from Portail DF', { + userId: user.id, + email: user.email, + frEntiere: perimeter.fr_entiere, + communesCount: perimeter.comm?.length || 0, + departmentsCount: perimeter.dep?.length || 0, + regionsCount: perimeter.reg?.length || 0, + epciCount: perimeter.epci?.length || 0 + }); + } + } + } + } catch (error) { + // Log error but don't fail login + logger.error('Failed to refresh authorized establishments at login', { + userId: user.id, + email: user.email, + error + }); + } +} + async function signIn(request: Request, response: Response) { const payload = request.body; - const user = await userRepository.getByEmail(payload.email); + // Use getByEmailIncludingDeleted to be able to detect deleted users + // and return a proper 403 error instead of 401 + const user = await userRepository.getByEmailIncludingDeleted(payload.email); if (!user) { throw new AuthenticationFailedError(); } + // Check if user account is deleted before password validation + // to return proper 403 error for deleted accounts + if (user.deletedAt) { + logger.warn('Login attempt on deleted account', { + userId: user.id, + email: user.email, + deletedAt: user.deletedAt + }); + throw new UserDeletedError(); + } + const isPasswordValid = await bcrypt.compare(payload.password, user.password); if (!isPasswordValid) { throw new AuthenticationFailedError(); } + // Log suspended account login (but allow login - frontend will show modal) + if (user.suspendedAt) { + logger.info('Login on suspended account - modal will be displayed', { + userId: user.id, + email: user.email, + suspendedAt: user.suspendedAt, + suspendedCause: user.suspendedCause + }); + // Note: We don't throw an error here anymore. + // The login proceeds and the frontend displays the suspension modal + // based on user.suspendedAt and user.suspendedCause + } + // Check if 2FA is required for admin users if (user.role === UserRole.ADMIN && config.auth.admin2faEnabled) { logger.info('Admin user detected, generating 2FA code', { userId: user.id }); @@ -124,6 +342,29 @@ async function signInToEstablishment( throw new EstablishmentMissingError(establishmentId); } + // Get authorized establishments for multi-structure dropdown + const authorizedEstablishmentLinks = await userRepository.getAuthorizedEstablishments(user.id); + const authorizedEstablishmentIds = authorizedEstablishmentLinks + .filter((e) => e.hasCommitment) + .map((e) => e.establishmentId); + + // Fetch full establishment details for authorized establishments + let authorizedEstablishments: Awaited> = []; + if (authorizedEstablishmentIds.length > 1) { + authorizedEstablishments = await establishmentRepository.find({ + filters: { id: authorizedEstablishmentIds } + }); + } + + // Refresh authorized establishments from Portail DF at login + // This runs asynchronously and doesn't block login + refreshAuthorizedEstablishments(user).catch((error) => { + logger.error('Failed to refresh authorized establishments', { + userId: user.id, + error + }); + }); + const accessToken = jwt.sign( { userId: user.id, @@ -137,18 +378,44 @@ async function signInToEstablishment( response.status(constants.HTTP_STATUS_OK).json({ user: toUserDTO(user), establishment, - accessToken + accessToken, + // Include authorized establishments for multi-structure users + ...(authorizedEstablishments.length > 1 && { authorizedEstablishments }) }); } async function changeEstablishment(request: Request, response: Response) { const { user } = request as AuthenticatedRequest; + const establishmentId = request.params.establishmentId; - if (user.role !== UserRole.ADMIN && user.role !== UserRole.VISITOR) { + // ADMIN and VISITOR can change to any establishment + if (user.role === UserRole.ADMIN || user.role === UserRole.VISITOR) { + await signInToEstablishment(user, establishmentId, response); + return; + } + + // USUAL users can only change to their authorized establishments + const authorizedEstablishments = await userRepository.getAuthorizedEstablishments(user.id); + const authorizedIds = authorizedEstablishments + .filter((e) => e.hasCommitment) + .map((e) => e.establishmentId); + + if (!authorizedIds.includes(establishmentId)) { + logger.warn('USUAL user tried to change to unauthorized establishment', { + userId: user.id, + email: user.email, + requestedEstablishment: establishmentId, + authorizedEstablishments: authorizedIds + }); throw new AuthenticationFailedError(); } - const establishmentId = request.params.establishmentId; + // Update user's current establishment + await userRepository.update({ + ...user, + establishmentId, + updatedAt: new Date().toJSON() + }); await signInToEstablishment(user, establishmentId, response); } diff --git a/server/src/controllers/campaignController.ts b/server/src/controllers/campaignController.ts index 345c1194b1..c99a8f5b34 100644 --- a/server/src/controllers/campaignController.ts +++ b/server/src/controllers/campaignController.ts @@ -14,7 +14,8 @@ import { HOUSING_STATUS_LABELS, HousingFiltersDTO, HousingStatus, - nextStatus + nextStatus, + UserRole } from '@zerologementvacant/models'; import { slugify, timestamp } from '@zerologementvacant/utils'; import { createS3 } from '@zerologementvacant/utils/node'; @@ -127,17 +128,32 @@ const listValidators: ValidationChain[] = [ ]; async function list(request: Request, response: Response) { - const { auth } = request as AuthenticatedRequest; + const { auth, effectiveGeoCodes } = request as AuthenticatedRequest; const query = request.query as CampaignQuery; const sort = sortApi.parse( request.query.sort as string[] | undefined ); logger.info('List campaigns', query); + // ADMIN and VISITOR users bypass perimeter filtering + const isAdminOrVisitor = [UserRole.ADMIN, UserRole.VISITOR].includes( + auth.role + ); + // effectiveGeoCodes is undefined when no restriction applies (no perimeter or fr_entiere) + // effectiveGeoCodes is an array (possibly empty) when restriction applies + const hasPerimeterRestriction = effectiveGeoCodes !== undefined; + const campaigns = await campaignRepository.find({ filters: { establishmentId: auth.establishmentId, - groupIds: typeof query.groups === 'string' ? [query.groups] : query.groups + groupIds: + typeof query.groups === 'string' ? [query.groups] : query.groups, + // Only show campaigns where ALL housings are within user's perimeter (bypass for ADMIN/VISITOR) + // If effectiveGeoCodes is empty array, user should see nothing + geoCodes: + isAdminOrVisitor || !hasPerimeterRestriction + ? undefined + : effectiveGeoCodes }, sort }); diff --git a/server/src/controllers/groupController.ts b/server/src/controllers/groupController.ts index b37786f763..63995dc0f7 100644 --- a/server/src/controllers/groupController.ts +++ b/server/src/controllers/groupController.ts @@ -1,4 +1,4 @@ -import { GroupDTO, GroupPayloadDTO } from '@zerologementvacant/models'; +import { GroupDTO, GroupPayloadDTO, UserRole } from '@zerologementvacant/models'; import { Array, pipe, Predicate } from 'effect'; import { Request, RequestHandler, Response } from 'express'; import { AuthenticatedRequest } from 'express-jwt'; @@ -20,7 +20,7 @@ import { isArrayOf, isString, isUUIDParam } from '~/utils/validators'; import { HousingApi } from '~/models/HousingApi'; const list = async (request: Request, response: Response): Promise => { - const { auth } = request as AuthenticatedRequest; + const { auth, effectiveGeoCodes } = request as AuthenticatedRequest; const { establishmentId, userId } = auth; logger.info('Find groups', { @@ -28,9 +28,23 @@ const list = async (request: Request, response: Response): Promise => { establishment: establishmentId }); + // ADMIN and VISITOR users bypass perimeter filtering + const isAdminOrVisitor = [UserRole.ADMIN, UserRole.VISITOR].includes( + auth.role + ); + // effectiveGeoCodes is undefined when no restriction applies (no perimeter or fr_entiere) + // effectiveGeoCodes is an array (possibly empty) when restriction applies + const hasPerimeterRestriction = effectiveGeoCodes !== undefined; + const groups = await groupRepository.find({ filters: { - establishmentId + establishmentId, + // Only show groups where ALL housings are within user's perimeter (bypass for ADMIN/VISITOR) + // If effectiveGeoCodes is empty array, user should see nothing + geoCodes: + isAdminOrVisitor || !hasPerimeterRestriction + ? undefined + : effectiveGeoCodes } }); response.status(constants.HTTP_STATUS_OK).json(groups.map(toGroupDTO)); diff --git a/server/src/controllers/housingController.ts b/server/src/controllers/housingController.ts index 6429595524..453685006f 100644 --- a/server/src/controllers/housingController.ts +++ b/server/src/controllers/housingController.ts @@ -76,16 +76,18 @@ const getValidators = oneOf([ param('id').isUUID() // id ]); async function get(request: Request, response: Response) { - const { params, establishment } = request as AuthenticatedRequest; + const { params, establishment, effectiveGeoCodes } = + request as AuthenticatedRequest; logger.info('Get housing', params.id); const id = params.id.length !== 12 ? params.id : undefined; const localId = params.id.length === 12 ? params.id : undefined; + // effectiveGeoCodes is undefined when no restriction applies, use all establishment geoCodes const housing = await housingRepository.findOne({ establishment: establishment.id, - geoCode: establishment.geoCodes, + geoCode: effectiveGeoCodes ?? establishment.geoCodes, id, localId, includes: ['owner', 'perimeters', 'campaigns'] @@ -110,7 +112,7 @@ const list: RequestHandler< ListHousingPayload, HousingQuery > = async (request, response): Promise => { - const { auth, user, query } = request as AuthenticatedRequest< + const { auth, user, query, effectiveGeoCodes } = request as AuthenticatedRequest< never, HousingPaginatedResultApi, ListHousingPayload, @@ -125,13 +127,28 @@ const list: RequestHandler< const role = user.role; const sort = sortApi.parse(query.sort); const rawFilters = Struct.omit(query, 'paginate', 'page', 'perPage', 'sort'); + + // For non-admin users, apply user perimeter filtering via localities + const isAdminOrVisitor = [UserRole.ADMIN, UserRole.VISITOR].includes(role); + // effectiveGeoCodes is undefined when no restriction applies (no perimeter or fr_entiere) + // effectiveGeoCodes is an array (possibly empty) when restriction applies + const hasPerimeterRestriction = effectiveGeoCodes !== undefined; const filters: HousingFiltersApi = { ...rawFilters, establishmentIds: - [UserRole.ADMIN, UserRole.VISITOR].includes(role) && - rawFilters?.establishmentIds?.length + isAdminOrVisitor && rawFilters?.establishmentIds?.length ? rawFilters?.establishmentIds - : [auth.establishmentId] + : [auth.establishmentId], + // Apply user perimeter filtering for non-admin users + // If effectiveGeoCodes is empty array, user should see nothing (no communes in their perimeter) + localities: + isAdminOrVisitor || !hasPerimeterRestriction + ? rawFilters.localities + : rawFilters.localities?.length + ? rawFilters.localities.filter((loc) => + effectiveGeoCodes.includes(loc) + ) + : effectiveGeoCodes }; logger.debug('List housing', { @@ -175,7 +192,7 @@ const count: RequestHandler< never, HousingFiltersDTO > = async (request, response): Promise => { - const { auth, query } = request as AuthenticatedRequest< + const { auth, query, effectiveGeoCodes } = request as AuthenticatedRequest< never, HousingCountApi, never, @@ -183,13 +200,26 @@ const count: RequestHandler< >; logger.debug('Count housings', { query }); + const isAdminOrVisitor = [UserRole.ADMIN, UserRole.VISITOR].includes( + auth.role + ); + // effectiveGeoCodes is undefined when no restriction applies (no perimeter or fr_entiere) + // effectiveGeoCodes is an array (possibly empty) when restriction applies + const hasPerimeterRestriction = effectiveGeoCodes !== undefined; const count = await housingRepository.count({ ...query, establishmentIds: - [UserRole.ADMIN, UserRole.VISITOR].includes(auth.role) && - query.establishmentIds?.length + isAdminOrVisitor && query.establishmentIds?.length ? query.establishmentIds - : [auth.establishmentId] + : [auth.establishmentId], + // Apply user perimeter filtering for non-admin users + // If effectiveGeoCodes is empty array, user should see nothing + localities: + isAdminOrVisitor || !hasPerimeterRestriction + ? query.localities + : query.localities?.length + ? query.localities.filter((loc) => effectiveGeoCodes.includes(loc)) + : effectiveGeoCodes }); response.status(constants.HTTP_STATUS_OK).json(count); }; @@ -207,16 +237,20 @@ const create: RequestHandler< HousingCreationPayload, never > = async (request, response): Promise => { - const { auth, body, establishment } = request as AuthenticatedRequest< - never, - HousingDTO, - HousingCreationPayload, - never - >; + const { auth, body, establishment, effectiveGeoCodes } = + request as AuthenticatedRequest< + never, + HousingDTO, + HousingCreationPayload, + never + >; + + // effectiveGeoCodes is undefined when no restriction applies, use all establishment geoCodes + const allowedGeoCodes = effectiveGeoCodes ?? establishment.geoCodes; const existing = await housingRepository.findOne({ establishment: establishment.id, - geoCode: establishment.geoCodes, + geoCode: allowedGeoCodes, localId: body.localId }); if (existing) { @@ -228,7 +262,7 @@ const create: RequestHandler< }); if ( !datafoncierHousing || - !establishment.geoCodes.includes(datafoncierHousing.idcom) + !allowedGeoCodes.includes(datafoncierHousing.idcom) ) { throw new HousingMissingError(body.localId); } @@ -424,16 +458,18 @@ async function update( request: Request, response: Response ): Promise { - const { auth, body, establishment, params } = request as AuthenticatedRequest< - HousingPathParams, - HousingDTO, - HousingUpdatePayloadDTO - >; - + const { auth, body, establishment, effectiveGeoCodes, params } = + request as AuthenticatedRequest< + HousingPathParams, + HousingDTO, + HousingUpdatePayloadDTO + >; + + // effectiveGeoCodes is undefined when no restriction applies, use all establishment geoCodes const housing = await housingRepository.findOne({ establishment: establishment.id, id: params.id, - geoCode: establishment.geoCodes, + geoCode: effectiveGeoCodes ?? establishment.geoCodes, includes: ['owner'] }); if (!housing) { @@ -516,21 +552,37 @@ const updateMany: RequestHandler< ReadonlyArray, HousingBatchUpdatePayload > = async (request, response): Promise => { - const { body, establishment, user } = request as AuthenticatedRequest< - never, - ReadonlyArray, - HousingBatchUpdatePayload - >; + const { body, establishment, user, effectiveGeoCodes } = + request as AuthenticatedRequest< + never, + ReadonlyArray, + HousingBatchUpdatePayload + >; logger.info('Updating many housings...', { body }); + const isAdminOrVisitor = [UserRole.ADMIN, UserRole.VISITOR].includes( + user.role + ); + // effectiveGeoCodes is undefined when no restriction applies (no perimeter or fr_entiere) + // effectiveGeoCodes is an array (possibly empty) when restriction applies + const hasPerimeterRestriction = effectiveGeoCodes !== undefined; const housings = await housingRepository.find({ filters: { ...body.filters, establishmentIds: - [UserRole.ADMIN, UserRole.VISITOR].includes(user.role) && - body.filters.establishmentIds?.length + isAdminOrVisitor && body.filters.establishmentIds?.length ? body.filters.establishmentIds - : [establishment.id] + : [establishment.id], + // Apply user perimeter filtering for non-admin users + // If effectiveGeoCodes is empty array, user should see nothing + localities: + isAdminOrVisitor || !hasPerimeterRestriction + ? body.filters.localities + : body.filters.localities?.length + ? body.filters.localities.filter((loc) => + effectiveGeoCodes.includes(loc) + ) + : effectiveGeoCodes }, includes: ['campaigns', 'owner'], pagination: { paginate: false } diff --git a/server/src/controllers/housingExportController.ts b/server/src/controllers/housingExportController.ts index c471b3c79b..4e39dffff7 100644 --- a/server/src/controllers/housingExportController.ts +++ b/server/src/controllers/housingExportController.ts @@ -40,7 +40,7 @@ const exportCampaignValidators: ValidationChain[] = [ ]; async function exportCampaign(request: Request, response: Response) { - const { auth, establishment, params } = request as AuthenticatedRequest; + const { auth, effectiveGeoCodes, params } = request as AuthenticatedRequest; logger.info('Export campaign', { id: params.id @@ -72,7 +72,7 @@ async function exportCampaign(request: Request, response: Response) { filters: { campaignIds: [campaign.id], establishmentIds: [auth.establishmentId], - localities: establishment.geoCodes + localities: effectiveGeoCodes }, includes: ['owner', 'campaigns', 'precisions'] }); diff --git a/server/src/controllers/localityController.ts b/server/src/controllers/localityController.ts index 7d0f897a07..4254a1a762 100644 --- a/server/src/controllers/localityController.ts +++ b/server/src/controllers/localityController.ts @@ -1,3 +1,4 @@ +import { UserRole } from '@zerologementvacant/models'; import { Request, Response } from 'express'; import { AuthenticatedRequest } from 'express-jwt'; import { body, param, query } from 'express-validator'; @@ -29,12 +30,29 @@ async function getLocality(request: Request, response: Response) { const listLocalitiesValidators = [query('establishmentId').notEmpty().isUUID()]; async function listLocalities(request: Request, response: Response) { + const { auth, effectiveGeoCodes } = request as AuthenticatedRequest; const establishmentId = request.query.establishmentId as string; logger.info('List localities', { establishment: establishmentId }); + + // ADMIN and VISITOR users bypass perimeter filtering + // Note: This route can be called unauthenticated, so check if auth exists + const isAdminOrVisitor = auth + ? [UserRole.ADMIN, UserRole.VISITOR].includes(auth.role) + : false; + // effectiveGeoCodes is undefined when no restriction applies (no perimeter or fr_entiere) + // effectiveGeoCodes is an array (possibly empty) when restriction applies + const hasPerimeterRestriction = effectiveGeoCodes !== undefined; + const localities = await localityRepository.find({ filters: { - establishmentId + establishmentId, + // Filter by user perimeter if available (bypass for ADMIN/VISITOR) + // If effectiveGeoCodes is empty array, user should see nothing + geoCodes: + isAdminOrVisitor || !hasPerimeterRestriction + ? undefined + : effectiveGeoCodes } }); response.status(constants.HTTP_STATUS_OK).json(localities); diff --git a/server/src/controllers/userController.ts b/server/src/controllers/userController.ts index 9ea0ebf5c7..9aedc07056 100644 --- a/server/src/controllers/userController.ts +++ b/server/src/controllers/userController.ts @@ -25,7 +25,12 @@ import { SALT_LENGTH, toUserDTO, UserApi } from '~/models/UserApi'; import establishmentRepository from '~/repositories/establishmentRepository'; import prospectRepository from '~/repositories/prospectRepository'; import userRepository from '~/repositories/userRepository'; +import ceremaService from '~/services/ceremaService'; import { isTestAccount } from '~/services/ceremaService/consultUserService'; +import { + verifyAccessRights, + accessErrorsToSuspensionCause +} from '~/services/ceremaService/perimeterService'; import mailService from '~/services/mailService'; type ListQuery = UserFilters; @@ -106,6 +111,53 @@ async function create(request: Request, response: Response) { throw new EstablishmentMissingError(body.establishmentId); } + // Re-verify Portail DF rights at account creation time + // The prospect may have been created some time ago and rights may have changed + const ceremaUsers = await ceremaService.consultUsers(body.email); + + // Find the user entry matching this establishment + // Note: '*' is a wildcard SIREN used in mock service for tests + const matchingCeremaUser = ceremaUsers.find( + (user) => user.establishmentSiren === userEstablishment.siren || user.establishmentSiren === '*' + ); + + if (!matchingCeremaUser) { + logger.warn('No matching Portail DF user found for establishment', { + email: body.email, + establishmentId: body.establishmentId, + establishmentSiren: userEstablishment.siren, + ceremaUsers + }); + throw new ProspectInvalidError(prospect); + } + + // Check structure LOVAC commitment (acces_lovac date in future) + if (!matchingCeremaUser.hasCommitment) { + logger.warn('User does not have valid LOVAC commitment at account creation', { + email: body.email, + establishmentId: body.establishmentId, + establishmentSiren: userEstablishment.siren + }); + throw new ProspectInvalidError(prospect); + } + + // Verify access rights: LOVAC access level and geographic perimeter (pass SIREN for EPCI perimeter check) + const accessRights = verifyAccessRights( + matchingCeremaUser, + userEstablishment.geoCodes, + userEstablishment.siren + ); + + if (!accessRights.isValid) { + logger.warn('User access rights verification failed at account creation', { + email: body.email, + establishmentId: body.establishmentId, + errors: accessRights.errors, + suspensionCause: accessErrorsToSuspensionCause(accessRights.errors) + }); + throw new ProspectInvalidError(prospect); + } + const user: UserApi = { id: uuidv4(), email: body.email, @@ -142,6 +194,50 @@ async function create(request: Request, response: Response) { const createdUser = await userRepository.insert(user); + // Populate users_establishments with all establishments the user has access to + // Filter Cerema users that have LOVAC commitment and find matching establishments + const ceremaUsersWithCommitment = ceremaUsers.filter((cu) => cu.hasCommitment); + // Filter out the wildcard SIREN '*' used in mock tests (not a valid SIREN for DB queries) + const establishmentSirens = ceremaUsersWithCommitment + .map((cu) => cu.establishmentSiren) + .filter((siren) => siren !== '*'); + + // Find all known establishments matching the SIRENs + const knownEstablishments = establishmentSirens.length > 0 + ? await establishmentRepository.find({ + filters: { siren: establishmentSirens } + }) + : []; + + // Create authorized establishments entries + const authorizedEstablishments = knownEstablishments.map((est) => { + const ceremaUser = ceremaUsersWithCommitment.find( + (cu) => cu.establishmentSiren === est.siren || cu.establishmentSiren === '*' + ); + return { + establishmentId: est.id, + establishmentSiren: est.siren, + hasCommitment: ceremaUser?.hasCommitment ?? false + }; + }); + + // Store authorized establishments (multi-structure support) + if (authorizedEstablishments.length > 0) { + await userRepository.setAuthorizedEstablishments( + createdUser.id, + authorizedEstablishments + ); + + const isMultiStructure = authorizedEstablishments.filter((e) => e.hasCommitment).length > 1; + if (isMultiStructure) { + logger.info('User created as multi-structure user', { + userId: createdUser.id, + email: createdUser.email, + authorizedEstablishmentsCount: authorizedEstablishments.length + }); + } + } + if (!userEstablishment.available) { await establishmentRepository.setAvailable(userEstablishment); } diff --git a/server/src/errors/userDeletedError.ts b/server/src/errors/userDeletedError.ts new file mode 100644 index 0000000000..7d3358827f --- /dev/null +++ b/server/src/errors/userDeletedError.ts @@ -0,0 +1,13 @@ +import { constants } from 'http2'; + +import { HttpError } from './httpError'; + +export default class UserDeletedError extends HttpError implements HttpError { + constructor() { + super({ + name: 'UserDeletedError', + message: 'User account has been deleted.', + status: constants.HTTP_STATUS_FORBIDDEN + }); + } +} diff --git a/server/src/errors/userSuspendedError.ts b/server/src/errors/userSuspendedError.ts new file mode 100644 index 0000000000..65930ddb8f --- /dev/null +++ b/server/src/errors/userSuspendedError.ts @@ -0,0 +1,15 @@ +import { constants } from 'http2'; + +import { HttpError } from './httpError'; + +export default class UserSuspendedError extends HttpError implements HttpError { + constructor(cause?: string) { + super({ + name: 'UserSuspendedError', + message: cause + ? `User account is suspended: ${cause}` + : 'User account is suspended.', + status: constants.HTTP_STATUS_FORBIDDEN + }); + } +} diff --git a/server/src/infra/database/migrations/20251217140000_users-establishments.ts b/server/src/infra/database/migrations/20251217140000_users-establishments.ts new file mode 100644 index 0000000000..7af0dae137 --- /dev/null +++ b/server/src/infra/database/migrations/20251217140000_users-establishments.ts @@ -0,0 +1,62 @@ +import { Knex } from 'knex'; + +/** + * Create users_establishments junction table for multi-structure support. + * + * This table allows users to be associated with multiple establishments + * when they have LOVAC access on multiple structures in Portail DF. + * + * The existing users.establishment_id column remains as the "current" + * or "primary" establishment for the user. + */ +export async function up(knex: Knex): Promise { + await knex.schema.createTable('users_establishments', (table) => { + table.uuid('user_id').notNullable() + .references('id').inTable('users') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + + table.uuid('establishment_id').notNullable() + .references('id').inTable('establishments') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + + // Store the SIREN for quick lookups without join to establishments + table.string('establishment_siren', 9).notNullable(); + + // Whether this establishment has valid LOVAC commitment + table.boolean('has_commitment').notNullable().defaultTo(false); + + // Timestamps + table.timestamp('created_at').notNullable().defaultTo(knex.fn.now()); + table.timestamp('updated_at').notNullable().defaultTo(knex.fn.now()); + + // Composite primary key + table.primary(['user_id', 'establishment_id']); + + // Index for quick user lookups + table.index(['user_id']); + + // Index for quick establishment lookups + table.index(['establishment_id']); + }); + + // Migrate existing user-establishment relationships + // This populates the junction table with existing relationships + await knex.raw(` + INSERT INTO users_establishments (user_id, establishment_id, establishment_siren, has_commitment) + SELECT + u.id as user_id, + u.establishment_id, + e.siren as establishment_siren, + true as has_commitment + FROM users u + INNER JOIN establishments e ON u.establishment_id = e.id + WHERE u.establishment_id IS NOT NULL + AND u.deleted_at IS NULL + `); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('users_establishments'); +} diff --git a/server/src/infra/database/migrations/20251217160000_user_perimeters.ts b/server/src/infra/database/migrations/20251217160000_user_perimeters.ts new file mode 100644 index 0000000000..ca44f0dad1 --- /dev/null +++ b/server/src/infra/database/migrations/20251217160000_user_perimeters.ts @@ -0,0 +1,34 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('user_perimeters', (table) => { + table.uuid('user_id').primary(); + table.specificType('geo_codes', 'TEXT[]').notNullable().defaultTo('{}'); + table.specificType('departments', 'TEXT[]').notNullable().defaultTo('{}'); + table.specificType('regions', 'TEXT[]').notNullable().defaultTo('{}'); + table.boolean('fr_entiere').notNullable().defaultTo(false); + table.timestamp('updated_at').notNullable().defaultTo(knex.fn.now()); + + table + .foreign('user_id') + .references('id') + .inTable('users') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + }); + + // Index for efficient filtering + await knex.raw(` + CREATE INDEX idx_user_perimeters_geo_codes ON user_perimeters USING GIN (geo_codes); + `); + await knex.raw(` + CREATE INDEX idx_user_perimeters_departments ON user_perimeters USING GIN (departments); + `); + await knex.raw(` + CREATE INDEX idx_user_perimeters_regions ON user_perimeters USING GIN (regions); + `); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTable('user_perimeters'); +} diff --git a/server/src/infra/database/migrations/20251223100000_user_perimeters_epci.ts b/server/src/infra/database/migrations/20251223100000_user_perimeters_epci.ts new file mode 100644 index 0000000000..66a4d8b1b8 --- /dev/null +++ b/server/src/infra/database/migrations/20251223100000_user_perimeters_epci.ts @@ -0,0 +1,20 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + // Add epci column to user_perimeters table + await knex.schema.alterTable('user_perimeters', (table) => { + table.specificType('epci', 'TEXT[]').notNullable().defaultTo('{}'); + }); + + // Index for efficient EPCI filtering + await knex.raw(` + CREATE INDEX idx_user_perimeters_epci ON user_perimeters USING GIN (epci); + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(`DROP INDEX IF EXISTS idx_user_perimeters_epci;`); + await knex.schema.alterTable('user_perimeters', (table) => { + table.dropColumn('epci'); + }); +} diff --git a/server/src/infra/database/seeds/development/20240404235457_users.ts b/server/src/infra/database/seeds/development/20240404235457_users.ts index 25f6247623..f7e30770c9 100644 --- a/server/src/infra/database/seeds/development/20240404235457_users.ts +++ b/server/src/infra/database/seeds/development/20240404235457_users.ts @@ -196,6 +196,51 @@ async function createBaseUsers( role: UserRole.USUAL, suspendedAt: now, suspendedCause: 'droits utilisateur expires, droits structure expires, cgu vides' + }), + // New suspension causes for Portail DF access verification + createBaseUser({ + email: 'test.suspended.access@zlv.fr', + password: hashedPassword, + firstName: 'Suspendu', + lastName: 'Niveau Accès', + establishmentId: strasbourg.id, + activatedAt: now, + role: UserRole.USUAL, + suspendedAt: now, + suspendedCause: 'niveau_acces_invalide' + }), + createBaseUser({ + email: 'test.suspended.perimeter@zlv.fr', + password: hashedPassword, + firstName: 'Suspendu', + lastName: 'Périmètre', + establishmentId: saintLo.id, + activatedAt: now, + role: UserRole.USUAL, + suspendedAt: now, + suspendedCause: 'perimetre_invalide' + }), + createBaseUser({ + email: 'test.suspended.access.perimeter@zlv.fr', + password: hashedPassword, + firstName: 'Suspendu', + lastName: 'Accès + Périmètre', + establishmentId: strasbourg.id, + activatedAt: now, + role: UserRole.USUAL, + suspendedAt: now, + suspendedCause: 'niveau_acces_invalide, perimetre_invalide' + }), + // Deleted user - for testing account deletion (LOGIN-09) + createBaseUser({ + email: 'test.deleted@zlv.fr', + password: hashedPassword, + firstName: 'Supprimé', + lastName: 'Compte', + establishmentId: strasbourg.id, + activatedAt: now, + role: UserRole.USUAL, + deletedAt: now }) ]; } diff --git a/server/src/middlewares/auth.ts b/server/src/middlewares/auth.ts index 0d83ee2f36..f0157d0e0b 100644 --- a/server/src/middlewares/auth.ts +++ b/server/src/middlewares/auth.ts @@ -8,7 +8,9 @@ import EstablishmentMissingError from '~/errors/establishmentMissingError'; import ForbiddenError from '~/errors/forbiddenError'; import UserMissingError from '~/errors/userMissingError'; import config from '~/infra/config'; +import { filterGeoCodesByPerimeter } from '~/models/UserPerimeterApi'; import establishmentRepository from '~/repositories/establishmentRepository'; +import userPerimeterRepository from '~/repositories/userPerimeterRepository'; import userRepository from '~/repositories/userRepository'; interface CheckOptions { @@ -38,6 +40,10 @@ export function userCheck(options?: CheckOptions) { promise: true, primitive: true }); + const getUserPerimeter = memoize(userPerimeterRepository.get, { + promise: true, + primitive: true + }); return async (request: Request, _: Response, next: NextFunction) => { if (!request.auth || !request.auth.userId) { @@ -47,9 +53,10 @@ export function userCheck(options?: CheckOptions) { return next(); } - const [user, establishment] = await Promise.all([ + const [user, establishment, userPerimeter] = await Promise.all([ getUser(request.auth.userId), - getEstablishment(request.auth.establishmentId) + getEstablishment(request.auth.establishmentId), + getUserPerimeter(request.auth.userId) ]); if (!user) { // Should never happen @@ -62,6 +69,14 @@ export function userCheck(options?: CheckOptions) { request.user = user; request.establishment = establishment; + request.userPerimeter = userPerimeter; + // Compute filtered geoCodes based on user perimeter + // Pass establishment SIREN for EPCI perimeter check + request.effectiveGeoCodes = filterGeoCodesByPerimeter( + establishment.geoCodes, + userPerimeter, + establishment.siren + ); next(); }; } diff --git a/server/src/models/CampaignFiltersApi.ts b/server/src/models/CampaignFiltersApi.ts index 0f84c1ba3b..92a490da97 100644 --- a/server/src/models/CampaignFiltersApi.ts +++ b/server/src/models/CampaignFiltersApi.ts @@ -9,6 +9,11 @@ import { export interface CampaignFiltersApi { establishmentId: string; groupIds?: string[]; + /** + * If provided, only return campaigns where ALL housings are within these geoCodes. + * Campaigns with any housing outside these geoCodes will be excluded. + */ + geoCodes?: string[]; } export interface CampaignQuery { diff --git a/server/src/models/UserPerimeterApi.ts b/server/src/models/UserPerimeterApi.ts new file mode 100644 index 0000000000..5f87af15ae --- /dev/null +++ b/server/src/models/UserPerimeterApi.ts @@ -0,0 +1,175 @@ +/** + * User perimeter from Portail DF + * Stored in database to filter housings/campaigns/groups by geographic scope + */ +export interface UserPerimeterApi { + userId: string; + geoCodes: string[]; + departments: string[]; + regions: string[]; + epci: string[]; // EPCI SIREN codes (9 chars) - for EPCI-level perimeters + frEntiere: boolean; + updatedAt: string; +} + +/** + * Check if a commune (by its INSEE code) is within the user's perimeter + */ +export function isCommuneInUserPerimeter( + communeCode: string, + perimeter: UserPerimeterApi +): boolean { + // France entière = access to everything + if (perimeter.frEntiere) { + return true; + } + + // Direct commune match + if (perimeter.geoCodes.includes(communeCode)) { + return true; + } + + // Department match + const departmentCode = getDepartmentFromCommune(communeCode); + if (departmentCode && perimeter.departments.includes(departmentCode)) { + return true; + } + + // Region match + const regionCode = getRegionFromDepartment(departmentCode); + if (regionCode && perimeter.regions.includes(regionCode)) { + return true; + } + + return false; +} + +/** + * Extract department code from a commune INSEE code + */ +function getDepartmentFromCommune(communeCode: string): string { + if (!communeCode || communeCode.length < 2) { + return ''; + } + + // DOM-TOM departments start with 97 and have 3-digit codes + if (communeCode.startsWith('97')) { + return communeCode.substring(0, 3); + } + + // Corsica: 2A and 2B + if (communeCode.startsWith('2A') || communeCode.startsWith('2B')) { + return communeCode.substring(0, 2); + } + + // Metropolitan France: 2-digit department codes + return communeCode.substring(0, 2); +} + +/** + * Extract region code from a department code + */ +function getRegionFromDepartment(departmentCode: string): string | null { + const departmentToRegion: Record = { + // Auvergne-Rhône-Alpes (84) + '01': '84', '03': '84', '07': '84', '15': '84', '26': '84', '38': '84', + '42': '84', '43': '84', '63': '84', '69': '84', '73': '84', '74': '84', + // Bourgogne-Franche-Comté (27) + '21': '27', '25': '27', '39': '27', '58': '27', '70': '27', '71': '27', + '89': '27', '90': '27', + // Bretagne (53) + '22': '53', '29': '53', '35': '53', '56': '53', + // Centre-Val de Loire (24) + '18': '24', '28': '24', '36': '24', '37': '24', '41': '24', '45': '24', + // Corse (94) + '2A': '94', '2B': '94', + // Grand Est (44) + '08': '44', '10': '44', '51': '44', '52': '44', '54': '44', '55': '44', + '57': '44', '67': '44', '68': '44', '88': '44', + // Hauts-de-France (32) + '02': '32', '59': '32', '60': '32', '62': '32', '80': '32', + // Île-de-France (11) + '75': '11', '77': '11', '78': '11', '91': '11', '92': '11', '93': '11', + '94': '11', '95': '11', + // Normandie (28) + '14': '28', '27': '28', '50': '28', '61': '28', '76': '28', + // Nouvelle-Aquitaine (75) + '16': '75', '17': '75', '19': '75', '23': '75', '24': '75', '33': '75', + '40': '75', '47': '75', '64': '75', '79': '75', '86': '75', '87': '75', + // Occitanie (76) + '09': '76', '11': '76', '12': '76', '30': '76', '31': '76', '32': '76', + '34': '76', '46': '76', '48': '76', '65': '76', '66': '76', '81': '76', '82': '76', + // Pays de la Loire (52) + '44': '52', '49': '52', '53': '52', '72': '52', '85': '52', + // Provence-Alpes-Côte d'Azur (93) + '04': '93', '05': '93', '06': '93', '13': '93', '83': '93', '84': '93', + // DOM-TOM + '971': '01', // Guadeloupe + '972': '02', // Martinique + '973': '03', // Guyane + '974': '04', // La Réunion + '976': '06', // Mayotte + }; + + return departmentToRegion[departmentCode] || null; +} + +/** + * Filter establishment geoCodes based on user perimeter. + * Returns only the geoCodes that are within the user's perimeter. + * + * Priority of perimeter checks: + * 1. fr_entiere = true → no restriction + * 2. geoCodes (communes) not empty → filter by communes (most restrictive) + * 3. departments not empty → filter by departments + * 4. regions not empty → filter by regions + * 5. epci matches establishment SIREN → no restriction (full EPCI access) + * + * @param establishmentGeoCodes - All geoCodes from the establishment + * @param perimeter - User's perimeter from Portail DF (or null if not available) + * @param establishmentSiren - Optional establishment SIREN for EPCI perimeter check + * @returns undefined if no restriction applies (no perimeter, fr_entiere=true, or EPCI match with no commune restriction), + * or an array of filtered geoCodes (may be empty if no intersection) + */ +export function filterGeoCodesByPerimeter( + establishmentGeoCodes: string[], + perimeter: UserPerimeterApi | null, + establishmentSiren?: string | number +): string[] | undefined { + // If no perimeter, return undefined to indicate no restriction + if (!perimeter) { + return undefined; + } + + // If user has fr_entiere, they can see all establishment geoCodes (no restriction) + if (perimeter.frEntiere) { + return undefined; + } + + // Check if user has any commune/department/region restrictions + const hasGeoCodesRestriction = perimeter.geoCodes && perimeter.geoCodes.length > 0; + const hasDepartmentsRestriction = perimeter.departments && perimeter.departments.length > 0; + const hasRegionsRestriction = perimeter.regions && perimeter.regions.length > 0; + const hasGeoRestriction = hasGeoCodesRestriction || hasDepartmentsRestriction || hasRegionsRestriction; + + // If user has EPCI perimeter that matches the establishment SIREN, + // AND no more restrictive geo constraints (communes/departments/regions), + // they can see all establishment geoCodes (no restriction) + // Note: Convert SIREN to string for comparison since epci array contains strings + const sirenStr = String(establishmentSiren); + if ( + !hasGeoRestriction && + establishmentSiren && + perimeter.epci && + perimeter.epci.length > 0 && + perimeter.epci.includes(sirenStr) + ) { + return undefined; + } + + // Filter establishment geoCodes to only those within user's perimeter + // May return empty array if no geoCodes match (user should see nothing) + return establishmentGeoCodes.filter((geoCode) => + isCommuneInUserPerimeter(geoCode, perimeter) + ); +} diff --git a/server/src/repositories/campaignRepository.ts b/server/src/repositories/campaignRepository.ts index a6560a1ff2..a6043f5f5c 100644 --- a/server/src/repositories/campaignRepository.ts +++ b/server/src/repositories/campaignRepository.ts @@ -6,6 +6,7 @@ import { logger } from '~/infra/logger'; import queue from '~/infra/queue'; import { CampaignApi, CampaignSortApi } from '~/models/CampaignApi'; import { CampaignFiltersApi } from '~/models/CampaignFiltersApi'; +import { campaignsHousingTable } from '~/repositories/campaignHousingRepository'; import { sortQuery } from '~/models/SortApi'; import eventRepository from '~/repositories/eventRepository'; @@ -54,6 +55,29 @@ const filterQuery = (filters: CampaignFiltersApi) => { if (filters.groupIds?.length) { query.whereIn('group_id', filters.groupIds); } + // Filter campaigns to only those where ALL housings are within the user's perimeter + // Note: geoCodes is an array when a restriction applies + // - non-empty array: filter to campaigns with housings in these geoCodes + // - empty array: user should see NO campaigns (intersection with perimeter is empty) + if (filters?.geoCodes !== undefined) { + if (filters.geoCodes.length === 0) { + // Empty geoCodes means no access - return no campaigns + query.whereRaw('1 = 0'); + } else { + const geoCodes = filters.geoCodes; + query.whereNotExists(function () { + this.select(db.raw('1')) + .from(campaignsHousingTable) + .whereRaw( + `${campaignsHousingTable}.campaign_id = ${campaignsTable}.id` + ) + .whereRaw( + `${campaignsHousingTable}.housing_geo_code NOT IN (${geoCodes.map(() => '?').join(', ')})`, + geoCodes + ); + }); + } + } }; }; diff --git a/server/src/repositories/groupRepository.ts b/server/src/repositories/groupRepository.ts index ef827b1af2..56f83d5e40 100644 --- a/server/src/repositories/groupRepository.ts +++ b/server/src/repositories/groupRepository.ts @@ -22,6 +22,11 @@ export const GroupsHousing = (transaction: Knex = db) => interface FindOptions { filters: { establishmentId: string; + /** + * If provided, only return groups where ALL housings are within these geoCodes. + * Groups with any housing outside these geoCodes will be excluded. + */ + geoCodes?: string[]; }; } @@ -84,6 +89,11 @@ const listQuery = (query: Knex.QueryBuilder): void => { interface FilterOptions { establishmentId?: string; + /** + * If provided, only return groups where ALL housings are within these geoCodes. + * Groups with any housing outside these geoCodes will be excluded. + */ + geoCodes?: string[]; } const filterQuery = (opts?: FilterOptions) => { @@ -91,6 +101,27 @@ const filterQuery = (opts?: FilterOptions) => { if (opts?.establishmentId) { query.where(`${GROUPS_TABLE}.establishment_id`, opts.establishmentId); } + // Filter groups to only those where ALL housings are within the user's perimeter + // Note: geoCodes is an array when a restriction applies + // - non-empty array: filter to groups with housings in these geoCodes + // - empty array: user should see NO groups (intersection with perimeter is empty) + if (opts?.geoCodes !== undefined) { + if (opts.geoCodes.length === 0) { + // Empty geoCodes means no access - return no groups + query.whereRaw('1 = 0'); + } else { + const geoCodes = opts.geoCodes; + query.whereNotExists(function () { + this.select(db.raw('1')) + .from(GROUPS_HOUSING_TABLE) + .whereRaw(`${GROUPS_HOUSING_TABLE}.group_id = ${GROUPS_TABLE}.id`) + .whereRaw( + `${GROUPS_HOUSING_TABLE}.housing_geo_code NOT IN (${geoCodes.map(() => '?').join(', ')})`, + geoCodes + ); + }); + } + } }; }; diff --git a/server/src/repositories/housingOwnerRepository.ts b/server/src/repositories/housingOwnerRepository.ts index 16f5a81287..fd99610f6b 100644 --- a/server/src/repositories/housingOwnerRepository.ts +++ b/server/src/repositories/housingOwnerRepository.ts @@ -38,11 +38,20 @@ async function findByOwner( owner_id: owner.id }) .modify((query) => { - if (options?.geoCodes?.length) { - query.whereIn( - `${housingOwnersTable}.housing_geo_code`, - options.geoCodes - ); + // Filter by geoCodes (user perimeter filtering) + // Note: geoCodes is an array when a restriction applies + // - non-empty array: filter to housings in these geoCodes + // - empty array: user should see NO housings (intersection with perimeter is empty) + if (options?.geoCodes !== undefined) { + if (options.geoCodes.length === 0) { + // Empty geoCodes means no access - return no results + query.whereRaw('1 = 0'); + } else { + query.whereIn( + `${housingOwnersTable}.housing_geo_code`, + options.geoCodes as string[] + ); + } } }) .join(housingTable, (join) => { diff --git a/server/src/repositories/housingRepository.ts b/server/src/repositories/housingRepository.ts index 096dc5161a..3d7c798942 100644 --- a/server/src/repositories/housingRepository.ts +++ b/server/src/repositories/housingRepository.ts @@ -80,6 +80,16 @@ interface FindOptions extends PaginationOptions { async function find(opts: FindOptions): Promise { logger.debug('housingRepository.find', opts); + // If localities is explicitly set to an empty array, return no results + // This happens when a user's perimeter has no intersection with their establishment + if ( + opts.filters.localities !== undefined && + opts.filters.localities.length === 0 + ) { + logger.debug('housingRepository.find: empty localities, returning []'); + return []; + } + const [allowedGeoCodes, intercommunalities] = await Promise.all([ fetchGeoCodes(opts.filters.establishmentIds ?? []), fetchGeoCodes(opts.filters.intercommunalities ?? []) @@ -124,6 +134,16 @@ type StreamOptions = FindOptions & { * @param opts */ function stream(opts: StreamOptions): Highland.Stream { + // If localities is explicitly set to an empty array, return empty stream + // This happens when a user's perimeter has no intersection with their establishment + if ( + opts.filters.localities !== undefined && + opts.filters.localities.length === 0 + ) { + logger.debug('housingRepository.stream: empty localities, returning empty stream'); + return highland([]); + } + return highland(fetchGeoCodes(opts.filters?.establishmentIds ?? [])) .flatMap((geoCodes) => { return highland( @@ -160,6 +180,13 @@ function betterStream( async function count(filters: HousingFiltersApi): Promise { logger.debug('Count housing', filters); + // If localities is explicitly set to an empty array, return 0 + // This happens when a user's perimeter has no intersection with their establishment + if (filters.localities !== undefined && filters.localities.length === 0) { + logger.debug('housingRepository.count: empty localities, returning 0'); + return { housing: 0, owners: 0 }; + } + const [allowedGeoCodes, intercommunalities] = await Promise.all([ fetchGeoCodes(filters.establishmentIds ?? []), fetchGeoCodes( diff --git a/server/src/repositories/localityRepository.ts b/server/src/repositories/localityRepository.ts index ebbd8f6776..3da6c2b0d4 100644 --- a/server/src/repositories/localityRepository.ts +++ b/server/src/repositories/localityRepository.ts @@ -17,6 +17,10 @@ const logger = createLogger('localityRepository'); interface LocalityFilters { establishmentId?: string; + /** + * Filter by specific geoCodes (used for user perimeter filtering) + */ + geoCodes?: string[]; } interface FindOptions { @@ -40,7 +44,19 @@ async function get(geoCode: string): Promise { function filterQuery(filters?: LocalityFilters) { return (query: Knex.QueryBuilder): void => { - if (filters?.establishmentId) { + // Filter by specific geoCodes (user perimeter filtering takes priority) + // Note: geoCodes is an array when a restriction applies + // - non-empty array: filter to localities with these geoCodes + // - empty array: user should see NO localities (intersection with perimeter is empty) + if (filters?.geoCodes !== undefined) { + if (filters.geoCodes.length === 0) { + // Empty geoCodes means no access - return no localities + query.whereRaw('1 = 0'); + } else { + query.whereIn('geo_code', filters.geoCodes); + } + } else if (filters?.establishmentId) { + // Filter by establishment geoCodes (default behavior) query.whereIn( 'geo_code', Establishments() diff --git a/server/src/repositories/userPerimeterRepository.ts b/server/src/repositories/userPerimeterRepository.ts new file mode 100644 index 0000000000..0fb67e1536 --- /dev/null +++ b/server/src/repositories/userPerimeterRepository.ts @@ -0,0 +1,81 @@ +import db from '~/infra/database'; +import { logger } from '~/infra/logger'; +import { UserPerimeterApi } from '~/models/UserPerimeterApi'; + +export const userPerimeterTable = 'user_perimeters'; +export const UserPerimeters = (transaction = db) => + transaction(userPerimeterTable); + +async function upsert(perimeter: UserPerimeterApi): Promise { + logger.debug('Upsert user perimeter', { userId: perimeter.userId }); + + const dbo = formatUserPerimeterApi(perimeter); + + await UserPerimeters() + .insert(dbo) + .onConflict('user_id') + .merge({ + geo_codes: dbo.geo_codes, + departments: dbo.departments, + regions: dbo.regions, + epci: dbo.epci, + fr_entiere: dbo.fr_entiere, + updated_at: dbo.updated_at + }); +} + +async function get(userId: string): Promise { + logger.debug('Get user perimeter', { userId }); + + const perimeter = await UserPerimeters() + .select() + .where('user_id', userId) + .first(); + + return perimeter ? parseUserPerimeterApi(perimeter) : null; +} + +async function remove(userId: string): Promise { + logger.debug('Remove user perimeter', { userId }); + await UserPerimeters().where('user_id', userId).delete(); +} + +export interface UserPerimeterDBO { + user_id: string; + geo_codes: string[]; + departments: string[]; + regions: string[]; + epci: string[]; + fr_entiere: boolean; + updated_at: Date | string; +} + +export const parseUserPerimeterApi = ( + dbo: UserPerimeterDBO +): UserPerimeterApi => ({ + userId: dbo.user_id, + geoCodes: dbo.geo_codes || [], + departments: dbo.departments || [], + regions: dbo.regions || [], + epci: dbo.epci || [], + frEntiere: dbo.fr_entiere, + updatedAt: new Date(dbo.updated_at).toJSON() +}); + +export const formatUserPerimeterApi = ( + api: UserPerimeterApi +): UserPerimeterDBO => ({ + user_id: api.userId, + geo_codes: api.geoCodes, + departments: api.departments, + regions: api.regions, + epci: api.epci, + fr_entiere: api.frEntiere, + updated_at: new Date(api.updatedAt).toJSON() +}); + +export default { + upsert, + get, + remove +}; diff --git a/server/src/repositories/userRepository.ts b/server/src/repositories/userRepository.ts index c3475b7f38..4cfa3a837b 100644 --- a/server/src/repositories/userRepository.ts +++ b/server/src/repositories/userRepository.ts @@ -1,4 +1,4 @@ -import type { TimePerWeek, UserFilters } from '@zerologementvacant/models'; +import type { TimePerWeek, UserFilters, UserEstablishment } from '@zerologementvacant/models'; import { Knex } from 'knex'; import db, { notDeleted } from '~/infra/database'; @@ -7,6 +7,7 @@ import { PaginationApi, paginationQuery } from '~/models/PaginationApi'; import { UserApi } from '~/models/UserApi'; export const usersTable = 'users'; +export const usersEstablishmentsTable = 'users_establishments'; export const Users = (transaction = db) => transaction(usersTable); @@ -32,6 +33,16 @@ async function getByEmail(email: string): Promise { return result ? parseUserApi(result) : null; } +async function getByEmailIncludingDeleted(email: string): Promise { + logger.debug('Get user by email (including deleted)', email); + + const result = await Users() + .whereRaw('upper(email) = upper(?)', email) + .first(); + + return result ? parseUserApi(result) : null; +} + async function update(user: UserApi): Promise { logger.debug('Updating user...', { id: user.id }); await Users().where({ id: user.id }).update(formatUserApi(user)); @@ -92,6 +103,140 @@ async function remove(userId: string): Promise { await db(usersTable).where('id', userId).update({ deleted_at: new Date() }); } +// ============================================================================ +// User Establishments (multi-structure support) +// ============================================================================ + +export const UsersEstablishments = (transaction = db) => + transaction(usersEstablishmentsTable); + +export interface UserEstablishmentDBO { + user_id: string; + establishment_id: string; + establishment_siren: string; + has_commitment: boolean; + created_at: Date | string; + updated_at: Date | string; +} + +/** + * Get all authorized establishments for a user + */ +async function getAuthorizedEstablishments(userId: string): Promise { + logger.debug('Get authorized establishments for user', userId); + + const results = await UsersEstablishments() + .where('user_id', userId) + .orderBy('created_at', 'asc'); + + return results.map(parseUserEstablishment); +} + +/** + * Set authorized establishments for a user (replaces existing) + */ +async function setAuthorizedEstablishments( + userId: string, + establishments: Array<{ + establishmentId: string; + establishmentSiren: string; + hasCommitment: boolean; + }> +): Promise { + logger.info('Setting authorized establishments for user', { + userId, + count: establishments.length + }); + + await db.transaction(async (trx) => { + // Remove existing entries + await UsersEstablishments(trx).where('user_id', userId).delete(); + + // Insert new entries + if (establishments.length > 0) { + const now = new Date(); + await UsersEstablishments(trx).insert( + establishments.map((e) => ({ + user_id: userId, + establishment_id: e.establishmentId, + establishment_siren: e.establishmentSiren, + has_commitment: e.hasCommitment, + created_at: now, + updated_at: now + })) + ); + } + }); +} + +/** + * Add an authorized establishment for a user (upsert) + */ +async function addAuthorizedEstablishment( + userId: string, + establishment: { + establishmentId: string; + establishmentSiren: string; + hasCommitment: boolean; + } +): Promise { + logger.info('Adding authorized establishment for user', { + userId, + establishmentId: establishment.establishmentId + }); + + const now = new Date(); + await db.raw( + ` + INSERT INTO ${usersEstablishmentsTable} (user_id, establishment_id, establishment_siren, has_commitment, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, establishment_id) + DO UPDATE SET has_commitment = EXCLUDED.has_commitment, updated_at = EXCLUDED.updated_at + `, + [ + userId, + establishment.establishmentId, + establishment.establishmentSiren, + establishment.hasCommitment, + now, + now + ] + ); +} + +/** + * Check if user has access to a specific establishment + */ +async function hasAccessToEstablishment( + userId: string, + establishmentId: string +): Promise { + const result = await UsersEstablishments() + .where({ user_id: userId, establishment_id: establishmentId }) + .first(); + + return !!result; +} + +/** + * Check if user is multi-structure (has access to more than one establishment with commitment) + */ +async function isMultiStructure(userId: string): Promise { + const result = await UsersEstablishments() + .where({ user_id: userId, has_commitment: true }) + .count('establishment_id as count') + .first(); + + const count = result as { count: string } | undefined; + return Number(count?.count) > 1; +} + +const parseUserEstablishment = (dbo: UserEstablishmentDBO): UserEstablishment => ({ + establishmentId: dbo.establishment_id, + establishmentSiren: dbo.establishment_siren, + hasCommitment: dbo.has_commitment +}); + export interface UserDBO { id: string; email: string; @@ -190,10 +335,17 @@ export const formatUserApi = (userApi: UserApi): UserDBO => ({ export default { get, getByEmail, + getByEmailIncludingDeleted, update, count, find, insert, formatUserApi, - remove + remove, + // Multi-structure support + getAuthorizedEstablishments, + setAuthorizedEstablishments, + addAuthorizedEstablishment, + hasAccessToEstablishment, + isMultiStructure }; diff --git a/server/src/scripts/test-portaildf-rights/README.md b/server/src/scripts/test-portaildf-rights/README.md new file mode 100644 index 0000000000..0add9f3006 --- /dev/null +++ b/server/src/scripts/test-portaildf-rights/README.md @@ -0,0 +1,193 @@ +# Scripts de test des droits Portail DF + +Ces scripts permettent de tester les différents cas d'erreurs liés à la vérification des droits Portail DF lors de la **connexion** et de la **création de compte**. + +## Note importante + +Ces scripts sont destinés aux **tests manuels uniquement** et ne doivent **pas être commités**. + +## Prérequis + +Variables d'environnement requises : +```bash +export CEREMA_USERNAME="votre_username" +export CEREMA_PASSWORD="votre_password" +export CEREMA_API="https://portaildf.cerema.fr" # optionnel +``` + +## Scripts disponibles + +### 1. `test-portaildf-rights.ts` + +Teste un email et affiche les informations de droits Portail DF. + +```bash +# Tester un email spécifique +npx tsx server/src/scripts/test-portaildf-rights/test-portaildf-rights.ts email@example.fr + +# Tester les emails prédéfinis +npx tsx server/src/scripts/test-portaildf-rights/test-portaildf-rights.ts +``` + +### 2. `generate-test-cases.ts` + +Génère tous les cas de test possibles avec un tableau récapitulatif et le code seed à copier. + +```bash +npx tsx server/src/scripts/test-portaildf-rights/generate-test-cases.ts email@example.fr +``` + +### 3. `fetch-test-data.ts` + +Récupère les données réelles de Portail DF pour un utilisateur et génère le code seed correspondant. + +```bash +npx tsx server/src/scripts/test-portaildf-rights/fetch-test-data.ts email@example.fr +``` + +--- + +## Cas de test - CONNEXION + +Ces cas testent la connexion d'un utilisateur **existant** en base ZLV. + +| ID | Description | Périmètre | Niv.Accès | Structure | Suspendu | Résultat | Cause | +|-----------|------------------------------------------|-----------|-----------|-----------|----------|------------|-------------------------------------------| +| LOGIN-01 | Utilisateur actif - tous droits valides | ✅ | ✅ | ✅ | 🟢 NON | OK | - | +| LOGIN-02 | Suspendu - droits utilisateur expirés | ✅ | ✅ | ✅ | 🔴 OUI | SUSPENDED | droits utilisateur expires | +| LOGIN-03 | Suspendu - droits structure expirés | ✅ | ✅ | ❌ | 🔴 OUI | SUSPENDED | droits structure expires | +| LOGIN-04 | Suspendu - CGU non validées | ✅ | ✅ | ✅ | 🔴 OUI | SUSPENDED | cgu vides | +| LOGIN-05 | Suspendu - niveau accès invalide | ✅ | ❌ | ✅ | 🔴 OUI | SUSPENDED | niveau_acces_invalide | +| LOGIN-06 | Suspendu - périmètre invalide | ❌ | ✅ | ✅ | 🔴 OUI | SUSPENDED | perimetre_invalide | +| LOGIN-07 | Suspendu - multiple causes | ✅ | ✅ | ✅ | 🔴 OUI | SUSPENDED | droits utilisateur expires, droits structure expires, cgu vides | +| LOGIN-08 | Suspendu - accès + périmètre invalides | ❌ | ❌ | ✅ | 🔴 OUI | SUSPENDED | niveau_acces_invalide, perimetre_invalide | +| LOGIN-09 | Compte supprimé | ✅ | ✅ | ✅ | 🟢 NON | FORBIDDEN | - | + +### Résultats attendus - Connexion + +| Résultat | HTTP | Description | +|------------|------|-----------------------------------------------------------| +| OK | 200 | Connexion réussie, accès normal au tableau de bord | +| SUSPENDED | 200 | Connexion réussie, modal de suspension affiché | +| FORBIDDEN | 403 | Connexion refusée, compte supprimé (`deletedAt` défini) | + +> **Note** : Seul le cas FORBIDDEN (compte supprimé) bloque la connexion. Les utilisateurs suspendus peuvent se connecter et verront la modale de suspension. + +--- + +## Cas de test - CRÉATION DE COMPTE + +Ces cas testent la création d'un nouveau compte via un **prospect** et un **signup link**. + +| ID | Description | Périmètre | Niv.Accès | Structure | Résultat | Cause | +|------------|-------------------------------------------|-----------|-----------|-----------|----------|-------------------------------------------| +| CREATE-01 | Création - tous droits valides | ✅ | ✅ | ✅ | OK | - | +| CREATE-02 | Création - niveau accès invalide (BLOQUÉ) | ✅ | ❌ | ✅ | ERROR | niveau_acces_invalide | +| CREATE-03 | Création - périmètre invalide (BLOQUÉ) | ❌ | ✅ | ✅ | ERROR | perimetre_invalide | +| CREATE-04 | Création - accès + périmètre invalides | ❌ | ❌ | ✅ | ERROR | niveau_acces_invalide, perimetre_invalide | +| CREATE-05 | Création - droits structure expirés | ✅ | ✅ | ❌ | ERROR | droits structure expires | +| CREATE-06 | Création - CGU non validées | ✅ | ✅ | ✅ | ERROR | cgu vides | +| CREATE-07 | Création - droits utilisateur expirés | ✅ | ✅ | ✅ | ERROR | droits utilisateur expires | + +### Résultats attendus - Création de compte + +| Résultat | Description | +|----------|-------------------------------------------------------------| +| OK | Compte créé avec succès, redirection vers le tableau de bord | +| ERROR | Création bloquée, message d'erreur affiché | + +--- + +## Légende des colonnes + +- **Périmètre** : Le périmètre géographique du groupe Portail DF couvre les geo_codes de l'établissement ZLV +- **Niv.Accès** : Le groupe a `niveau_acces = 'lovac'` OU `lovac = true` +- **Structure** : La date `acces_lovac` de la structure est dans le futur +- **Suspendu** : L'utilisateur a `suspendedAt` défini en base (uniquement pour la connexion) + +## Causes de suspension / blocage + +| Cause | Description | +|------------------------------|----------------------------------------------------------------| +| `droits utilisateur expires` | Droits utilisateur expirés sur Portail DF | +| `droits structure expires` | `acces_lovac` NULL ou date expirée | +| `cgu vides` | CGU non validées sur Portail DF | +| `niveau_acces_invalide` | Groupe n'a pas `niveau_acces = 'lovac'` ni `lovac = true` | +| `perimetre_invalide` | Périmètre géographique ne couvre pas l'établissement ZLV | + +--- + +## Utilisateurs de test en seed (development) + +### Pour la CONNEXION + +| Cas ID | Email | Cause de suspension | Résultat attendu | +|----------|-------------------------------------------|---------------------------------------------------------------|------------------| +| LOGIN-01 | `test.strasbourg@zlv.fr` | (aucune - utilisateur normal Strasbourg) | OK | +| LOGIN-01 | `test.saintlo@zlv.fr` | (aucune - utilisateur normal Saint-Lô) | OK | +| LOGIN-02 | `test.suspended.user@zlv.fr` | `droits utilisateur expires` | SUSPENDED | +| LOGIN-03 | `test.suspended.structure@zlv.fr` | `droits structure expires` | SUSPENDED | +| LOGIN-04 | `test.suspended.cgu@zlv.fr` | `cgu vides` | SUSPENDED | +| LOGIN-05 | `test.suspended.access@zlv.fr` | `niveau_acces_invalide` | SUSPENDED | +| LOGIN-06 | `test.suspended.perimeter@zlv.fr` | `perimetre_invalide` | SUSPENDED | +| LOGIN-07 | `test.suspended.multiple@zlv.fr` | `droits utilisateur expires, droits structure expires, cgu vides` | SUSPENDED | +| LOGIN-08 | `test.suspended.access.perimeter@zlv.fr` | `niveau_acces_invalide, perimetre_invalide` | SUSPENDED | +| LOGIN-09 | `test.deleted@zlv.fr` | (compte supprimé - `deletedAt` défini) | FORBIDDEN | + +### Pour la CRÉATION DE COMPTE + +| Email | Signup Link ID | Résultat attendu | +|----------------------------------------|-----------------------------------|------------------| +| `test.create.valid@zlv.fr` | `create_01_signup_link` | OK | +| `test.create.invalid.access@zlv.fr` | `create_02_signup_link` | ERROR | +| `test.create.invalid.perimeter@zlv.fr` | `create_03_signup_link` | ERROR | +| `test.create.invalid.both@zlv.fr` | `create_04_signup_link` | ERROR | +| `test.create.expired.structure@zlv.fr` | `create_05_signup_link` | ERROR | +| `test.create.cgu.empty@zlv.fr` | `create_06_signup_link` | ERROR | +| `test.create.expired.user@zlv.fr` | `create_07_signup_link` | ERROR | + +--- + +## Comment tester manuellement + +### Tests de CONNEXION + +1. **Démarrer l'environnement de développement** + ```bash + yarn dev + ``` + +2. **Se connecter avec un utilisateur de test** + - Email : `test.suspended.access@zlv.fr` (ou autre email de la liste ci-dessus) + - Mot de passe : (défini par `TEST_PASSWORD`) + +3. **Vérifier le résultat attendu** : + - OK : Accès normal au tableau de bord + - SUSPENDED : Modal de suspension affiché avec le message approprié + - FORBIDDEN : Erreur 403, impossible de se connecter + +### Tests de CRÉATION DE COMPTE + +1. **Accéder au lien de création de compte** + ``` + http://localhost:3000/inscription/{signup_link_id} + ``` + +2. **Remplir le formulaire** avec l'email du prospect correspondant + +3. **Vérifier le résultat attendu** : + - OK : Compte créé avec succès + - ERROR : Message d'erreur affiché, compte non créé + +--- + +## Vérification du périmètre géographique + +Le périmètre est vérifié ainsi : + +1. Si `fr_entiere = true` → Accès à toute la France ✅ +2. Sinon, on vérifie si les geo_codes de l'établissement ZLV sont couverts par : + - `comm[]` : correspondance directe avec le code commune INSEE (5 chiffres) + - `dep[]` : les 2-3 premiers chiffres du geo_code correspondent au département + - `reg[]` : le département est dans la région (via mapping) + - `epci[]` : l'EPCI contient la commune (nécessite une table de mapping) diff --git a/server/src/scripts/test-portaildf-rights/fetch-test-data.ts b/server/src/scripts/test-portaildf-rights/fetch-test-data.ts new file mode 100644 index 0000000000..25f420e4e4 --- /dev/null +++ b/server/src/scripts/test-portaildf-rights/fetch-test-data.ts @@ -0,0 +1,352 @@ +/** + * Script pour récupérer les données de test depuis Portail DF + * et générer les seeds appropriés pour tester les erreurs de périmètre + * + * Usage: + * npx tsx server/src/scripts/test-portaildf-rights/fetch-test-data.ts [email] + * + * Exemples: + * npx tsx server/src/scripts/test-portaildf-rights/fetch-test-data.ts test.strasbourg@zlv.fr + * npx tsx server/src/scripts/test-portaildf-rights/fetch-test-data.ts + */ + +import 'dotenv/config'; + +const config = { + cerema: { + api: process.env.CEREMA_API || 'https://portaildf.cerema.fr', + username: process.env.CEREMA_USERNAME || '', + password: process.env.CEREMA_PASSWORD || '' + } +}; + +interface PortailDFUser { + email: string; + structure: number; + groupe: number; +} + +interface PortailDFStructure { + id: number; + siret: string; + nom: string; + acces_lovac: string | null; +} + +interface PortailDFGroup { + id_groupe: number; + nom: string; + structure: number; + perimetre: number; + niveau_acces: string; + df_ano: boolean; + df_non_ano: boolean; + lovac: boolean; +} + +interface PortailDFPerimeter { + perimetre_id: number; + origine: string; + fr_entiere: boolean; + reg: string[]; + dep: string[]; + epci: string[]; + comm: string[]; +} + +interface FullUserData { + email: string; + user: PortailDFUser; + structure: PortailDFStructure; + group: PortailDFGroup | null; + perimeter: PortailDFPerimeter | null; +} + +async function authenticate(): Promise { + console.log('🔐 Authentification...'); + + if (!config.cerema.username || !config.cerema.password) { + console.error('❌ CEREMA_USERNAME et CEREMA_PASSWORD requis'); + return null; + } + + const formData = new FormData(); + formData.append('username', config.cerema.username); + formData.append('password', config.cerema.password); + + const response = await fetch(`${config.cerema.api}/api/api-token-auth/`, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + console.error('❌ Échec authentification'); + return null; + } + + const data = await response.json() as { token: string }; + console.log('✅ Authentifié'); + return data.token; +} + +async function fetchAPI(token: string, endpoint: string): Promise { + const response = await fetch(`${config.cerema.api}${endpoint}`, { + method: 'GET', + headers: { + 'Authorization': `Token ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) return null; + return await response.json() as T; +} + +async function fetchUserData(token: string, email: string): Promise { + console.log(`\n📧 Récupération données pour: ${email}`); + + // Get user + const userResponse = await fetchAPI<{ count: number; results: PortailDFUser[] }>( + token, + `/api/utilisateurs/?email=${encodeURIComponent(email)}` + ); + + if (!userResponse || userResponse.count === 0) { + console.log(' ❌ Utilisateur non trouvé'); + return null; + } + + const user = userResponse.results[0]; + console.log(` ✅ Utilisateur trouvé (structure: ${user.structure}, groupe: ${user.groupe})`); + + // Get structure + const structure = await fetchAPI( + token, + `/api/structures/${user.structure}/` + ); + + if (!structure) { + console.log(' ❌ Structure non trouvée'); + return null; + } + + console.log(` 📦 Structure: ${structure.nom} (SIREN: ${structure.siret.substring(0, 9)})`); + console.log(` acces_lovac: ${structure.acces_lovac}`); + + // Get group + let group: PortailDFGroup | null = null; + let perimeter: PortailDFPerimeter | null = null; + + if (user.groupe) { + group = await fetchAPI(token, `/api/groupes/${user.groupe}/`); + if (group) { + console.log(` 👥 Groupe: ${group.nom}`); + console.log(` niveau_acces: ${group.niveau_acces}`); + console.log(` lovac: ${group.lovac}`); + + if (group.perimetre) { + perimeter = await fetchAPI( + token, + `/api/perimetres/${group.perimetre}/` + ); + if (perimeter) { + console.log(` 🗺️ Périmètre:`); + console.log(` fr_entiere: ${perimeter.fr_entiere}`); + console.log(` reg: [${perimeter.reg.join(', ')}]`); + console.log(` dep: [${perimeter.dep.join(', ')}]`); + console.log(` epci: [${perimeter.epci.join(', ')}]`); + console.log(` comm: [${perimeter.comm.join(', ')}]`); + } + } + } + } + + return { email, user, structure, group, perimeter }; +} + +function generateTestEstablishments(userData: FullUserData): void { + console.log('\n' + '='.repeat(80)); + console.log('📝 GÉNÉRATION DES ÉTABLISSEMENTS DE TEST'); + console.log('='.repeat(80)); + + const siren = userData.structure.siret.substring(0, 9); + const perimeter = userData.perimeter; + + console.log(`\nUtilisateur: ${userData.email}`); + console.log(`SIREN structure: ${siren}`); + + if (!perimeter) { + console.log('⚠️ Pas de périmètre, impossible de générer des tests'); + return; + } + + // Déterminer les geo_codes valides et invalides + let validGeoCodes: string[] = []; + let invalidGeoCodes: string[] = []; + + if (perimeter.fr_entiere) { + console.log('✅ France entière - tout périmètre est valide'); + validGeoCodes = ['75056']; // Paris + // Pour tester fr_entiere, on ne peut pas créer d'invalide côté périmètre + } else if (perimeter.dep.length > 0) { + // Périmètre départemental + const dept = perimeter.dep[0]; + validGeoCodes = [`${dept}001`, `${dept}002`]; // Communes du département + // Département invalide (différent) + const invalidDept = dept === '67' ? '68' : '67'; + invalidGeoCodes = [`${invalidDept}001`, `${invalidDept}002`]; + console.log(`📍 Périmètre départemental: ${dept}`); + } else if (perimeter.reg.length > 0) { + // Périmètre régional + const reg = perimeter.reg[0]; + console.log(`📍 Périmètre régional: ${reg}`); + // Nécessite un mapping région -> département + validGeoCodes = ['67482']; // Strasbourg si région Grand Est + invalidGeoCodes = ['13055']; // Marseille (autre région) + } else if (perimeter.epci.length > 0) { + console.log(`📍 Périmètre EPCI: ${perimeter.epci.join(', ')}`); + // EPCI = SIREN, nécessite une table de mapping EPCI -> communes + validGeoCodes = ['67482']; + invalidGeoCodes = ['13055']; + } else if (perimeter.comm.length > 0) { + validGeoCodes = perimeter.comm; + // Commune invalide (différente) + invalidGeoCodes = perimeter.comm[0] === '67482' ? ['13055'] : ['67482']; + console.log(`📍 Périmètre communal: ${perimeter.comm.join(', ')}`); + } + + console.log(`\n✅ Geo codes valides: [${validGeoCodes.join(', ')}]`); + console.log(`❌ Geo codes invalides: [${invalidGeoCodes.join(', ')}]`); + + // Générer le code seed + console.log('\n' + '-'.repeat(80)); + console.log('📋 CODE À AJOUTER DANS LES SEEDS:'); + console.log('-'.repeat(80)); + + const hasValidLovac = userData.group?.niveau_acces === 'lovac' || userData.group?.lovac === true; + + console.log(` +// ============================================================================ +// ÉTABLISSEMENTS DE TEST POUR VÉRIFICATION PÉRIMÈTRE +// Basé sur les données de: ${userData.email} +// Structure Portail DF: ${userData.structure.nom} (SIREN: ${siren}) +// ============================================================================ + +// Établissement avec périmètre VALIDE (devrait fonctionner) +export const TestEstablishmentValidPerimeter = { + id: faker.string.uuid(), + name: 'Test - Périmètre Valide', + siren: Number('${siren}'), // Même SIREN que la structure Portail DF + available: true, + localities_geo_code: [${validGeoCodes.map(c => `'${c}'`).join(', ')}], + kind: 'Commune' as const, + source: 'seed' as const, + updated_at: new Date() +}; + +// Établissement avec périmètre INVALIDE (devrait échouer avec perimetre_invalide) +export const TestEstablishmentInvalidPerimeter = { + id: faker.string.uuid(), + name: 'Test - Périmètre Invalide', + siren: Number('${siren}'), // Même SIREN mais geo_codes hors périmètre + available: true, + localities_geo_code: [${invalidGeoCodes.map(c => `'${c}'`).join(', ')}], + kind: 'Commune' as const, + source: 'seed' as const, + updated_at: new Date() +}; + +// Données Portail DF pour référence: +// - Groupe: ${userData.group?.nom || 'N/A'} +// - niveau_acces: ${userData.group?.niveau_acces || 'N/A'} +// - lovac: ${userData.group?.lovac ?? 'N/A'} +// - Périmètre fr_entiere: ${perimeter.fr_entiere} +// - Périmètre reg: [${perimeter.reg.join(', ')}] +// - Périmètre dep: [${perimeter.dep.join(', ')}] +// - Périmètre epci: [${perimeter.epci.join(', ')}] +// - Périmètre comm: [${perimeter.comm.join(', ')}] +`); + + // Générer les utilisateurs de test associés + console.log(` +// UTILISATEURS DE TEST ASSOCIÉS +// À ajouter dans le seed des users: + +// Utilisateur avec périmètre valide (pas de suspension) +createBaseUser({ + email: 'test.perimeter.valid@zlv.fr', + password: hashedPassword, + firstName: 'Test', + lastName: 'Périmètre Valide', + establishmentId: TestEstablishmentValidPerimeter.id, + activatedAt: now, + role: UserRole.USUAL, + suspendedAt: null, + suspendedCause: null +}), + +// Utilisateur avec périmètre invalide (suspendu) +createBaseUser({ + email: 'test.perimeter.invalid@zlv.fr', + password: hashedPassword, + firstName: 'Test', + lastName: 'Périmètre Invalide', + establishmentId: TestEstablishmentInvalidPerimeter.id, + activatedAt: now, + role: UserRole.USUAL, + suspendedAt: now, + suspendedCause: 'perimetre_invalide' +}), +`); + + if (!hasValidLovac) { + console.log(` +// ⚠️ ATTENTION: L'utilisateur n'a PAS d'accès LOVAC valide +// niveau_acces: ${userData.group?.niveau_acces} +// lovac: ${userData.group?.lovac} +// Les tests de périmètre échoueront aussi avec niveau_acces_invalide +`); + } +} + +async function main(): Promise { + console.log('='.repeat(80)); + console.log('🔍 RÉCUPÉRATION DES DONNÉES PORTAIL DF POUR TESTS'); + console.log('='.repeat(80)); + + const token = await authenticate(); + if (!token) { + process.exit(1); + } + + const email = process.argv[2]; + + if (email) { + const userData = await fetchUserData(token, email); + if (userData) { + generateTestEstablishments(userData); + } + } else { + // Tester plusieurs emails + const testEmails = [ + 'test.strasbourg@zlv.fr', + 'test.saintlo@zlv.fr', + ]; + + console.log('\n📋 Emails à tester:'); + testEmails.forEach(e => console.log(` - ${e}`)); + + for (const testEmail of testEmails) { + const userData = await fetchUserData(token, testEmail); + if (userData) { + generateTestEstablishments(userData); + } + } + } + + console.log('\n' + '='.repeat(80)); + console.log('✅ Terminé'); + console.log('='.repeat(80)); +} + +main().catch(console.error); diff --git a/server/src/scripts/test-portaildf-rights/generate-test-cases.ts b/server/src/scripts/test-portaildf-rights/generate-test-cases.ts new file mode 100644 index 0000000000..cb7d647584 --- /dev/null +++ b/server/src/scripts/test-portaildf-rights/generate-test-cases.ts @@ -0,0 +1,797 @@ +/** + * Script pour générer tous les cas de test de vérification des droits Portail DF + * + * Ce script récupère les données réelles de Portail DF et génère : + * - Des établissements de test avec différents périmètres + * - Des utilisateurs de test avec différentes combinaisons de droits (pour la CONNEXION) + * - Des prospects et signup links (pour la CRÉATION DE COMPTE) + * - Un tableau récapitulatif de tous les cas de test + * + * Usage: + * npx tsx server/src/scripts/test-portaildf-rights/generate-test-cases.ts [email] + * + * Exemple: + * npx tsx server/src/scripts/test-portaildf-rights/generate-test-cases.ts test.strasbourg@zlv.fr + */ + +import 'dotenv/config'; + +const config = { + cerema: { + api: process.env.CEREMA_API || 'https://portaildf.cerema.fr', + username: process.env.CEREMA_USERNAME || '', + password: process.env.CEREMA_PASSWORD || '' + } +}; + +// ============================================================================= +// TYPES +// ============================================================================= + +interface PortailDFUser { + email: string; + structure: number; + groupe: number; +} + +interface PortailDFStructure { + id: number; + siret: string; + nom: string; + acces_lovac: string | null; +} + +interface PortailDFGroup { + id_groupe: number; + nom: string; + structure: number; + perimetre: number; + niveau_acces: string; + df_ano: boolean; + df_non_ano: boolean; + lovac: boolean; +} + +interface PortailDFPerimeter { + perimetre_id: number; + origine: string; + fr_entiere: boolean; + reg: string[]; + dep: string[]; + epci: string[]; + comm: string[]; +} + +interface FullUserData { + email: string; + user: PortailDFUser; + structure: PortailDFStructure; + group: PortailDFGroup | null; + perimeter: PortailDFPerimeter | null; +} + +interface TestCase { + id: string; + description: string; + email: string; + establishmentName: string; + geoCodes: string[]; + perimeterValid: boolean; + accessLevelValid: boolean; + structureAccessValid: boolean; + expectedSuspended: boolean; + expectedCause: string | null; + loginResult: 'OK' | 'SUSPENDED' | 'FORBIDDEN'; + createAccountResult: 'OK' | 'ERROR'; + // Type de test + testType: 'login' | 'create_account' | 'both'; +} + +// Note: LoginTestCase et CreateAccountTestCase pourraient être utilisés +// pour un typage plus strict, mais pour l'instant on utilise TestCase directement + +// ============================================================================= +// API FUNCTIONS +// ============================================================================= + +async function authenticate(): Promise { + if (!config.cerema.username || !config.cerema.password) { + console.error('❌ CEREMA_USERNAME et CEREMA_PASSWORD requis'); + return null; + } + + const formData = new FormData(); + formData.append('username', config.cerema.username); + formData.append('password', config.cerema.password); + + const response = await fetch(`${config.cerema.api}/api/api-token-auth/`, { + method: 'POST', + body: formData + }); + + if (!response.ok) return null; + const data = await response.json() as { token: string }; + return data.token; +} + +async function fetchAPI(token: string, endpoint: string): Promise { + const response = await fetch(`${config.cerema.api}${endpoint}`, { + method: 'GET', + headers: { + 'Authorization': `Token ${token}`, + 'Content-Type': 'application/json' + } + }); + if (!response.ok) return null; + return await response.json() as T; +} + +async function fetchUserData(token: string, email: string): Promise { + const userResponse = await fetchAPI<{ count: number; results: PortailDFUser[] }>( + token, + `/api/utilisateurs/?email=${encodeURIComponent(email)}` + ); + + if (!userResponse || userResponse.count === 0) return null; + + const user = userResponse.results[0]; + const structure = await fetchAPI(token, `/api/structures/${user.structure}/`); + if (!structure) return null; + + let group: PortailDFGroup | null = null; + let perimeter: PortailDFPerimeter | null = null; + + if (user.groupe) { + group = await fetchAPI(token, `/api/groupes/${user.groupe}/`); + if (group?.perimetre) { + perimeter = await fetchAPI(token, `/api/perimetres/${group.perimetre}/`); + } + } + + return { email, user, structure, group, perimeter }; +} + +// ============================================================================= +// TEST CASE GENERATION +// ============================================================================= + +function getValidGeoCodes(perimeter: PortailDFPerimeter | null): string[] { + if (!perimeter) return ['67482']; // Default Strasbourg + + if (perimeter.fr_entiere) return ['75056']; // Paris + if (perimeter.comm.length > 0) return [perimeter.comm[0]]; + if (perimeter.dep.length > 0) return [`${perimeter.dep[0]}001`]; + if (perimeter.reg.length > 0) return ['67482']; // Default for region + + return ['67482']; +} + +function getInvalidGeoCodes(perimeter: PortailDFPerimeter | null): string[] { + if (!perimeter) return ['13055']; // Marseille + + if (perimeter.fr_entiere) return []; // Impossible d'avoir un périmètre invalide + + // Choisir un geo_code qui n'est PAS dans le périmètre + if (perimeter.dep.includes('67')) return ['13055']; // Marseille + if (perimeter.dep.includes('50')) return ['67482']; // Strasbourg + if (perimeter.dep.includes('13')) return ['67482']; // Strasbourg + + return ['13055']; // Default Marseille +} + +function generateTestCases(userData: FullUserData): { login: TestCase[]; createAccount: TestCase[] } { + const perimeter = userData.perimeter; + const validGeoCodes = getValidGeoCodes(perimeter); + const invalidGeoCodes = getInvalidGeoCodes(perimeter); + + const loginTestCases: TestCase[] = []; + const createAccountTestCases: TestCase[] = []; + + // ========================================================================== + // CAS DE TEST CONNEXION (LOGIN) + // L'utilisateur existe déjà en base ZLV + // ========================================================================== + + // LOGIN-01: Utilisateur actif avec tous droits valides + // NOTE: En seed, utilisez test.strasbourg@zlv.fr ou test.saintlo@zlv.fr pour ce cas + loginTestCases.push({ + id: 'LOGIN-01', + description: 'Utilisateur actif - tous droits valides', + email: `test.strasbourg@zlv.fr`, + establishmentName: 'Eurométropole de Strasbourg', + geoCodes: validGeoCodes, + perimeterValid: true, + accessLevelValid: true, + structureAccessValid: true, + expectedSuspended: false, + expectedCause: null, + loginResult: 'OK', + createAccountResult: 'OK', + testType: 'login' + }); + + // LOGIN-02: Utilisateur suspendu - droits utilisateur expirés + loginTestCases.push({ + id: 'LOGIN-02', + description: 'Suspendu - droits utilisateur expirés', + email: `test.suspended.user@zlv.fr`, + establishmentName: 'Eurométropole de Strasbourg', + geoCodes: validGeoCodes, + perimeterValid: true, + accessLevelValid: true, + structureAccessValid: true, + expectedSuspended: true, + expectedCause: 'droits utilisateur expires', + loginResult: 'SUSPENDED', + createAccountResult: 'ERROR', + testType: 'login' + }); + + // LOGIN-03: Utilisateur suspendu - droits structure expirés + loginTestCases.push({ + id: 'LOGIN-03', + description: 'Suspendu - droits structure expirés', + email: `test.suspended.structure@zlv.fr`, + establishmentName: 'Saint-Lô Agglo', + geoCodes: validGeoCodes, + perimeterValid: true, + accessLevelValid: true, + structureAccessValid: false, + expectedSuspended: true, + expectedCause: 'droits structure expires', + loginResult: 'SUSPENDED', + createAccountResult: 'ERROR', + testType: 'login' + }); + + // LOGIN-04: Utilisateur suspendu - CGU non validées + loginTestCases.push({ + id: 'LOGIN-04', + description: 'Suspendu - CGU non validées', + email: `test.suspended.cgu@zlv.fr`, + establishmentName: 'Eurométropole de Strasbourg', + geoCodes: validGeoCodes, + perimeterValid: true, + accessLevelValid: true, + structureAccessValid: true, + expectedSuspended: true, + expectedCause: 'cgu vides', + loginResult: 'SUSPENDED', + createAccountResult: 'ERROR', + testType: 'login' + }); + + // LOGIN-05: Utilisateur suspendu - niveau accès invalide + loginTestCases.push({ + id: 'LOGIN-05', + description: 'Suspendu - niveau accès invalide', + email: `test.suspended.access@zlv.fr`, + establishmentName: 'Eurométropole de Strasbourg', + geoCodes: validGeoCodes, + perimeterValid: true, + accessLevelValid: false, + structureAccessValid: true, + expectedSuspended: true, + expectedCause: 'niveau_acces_invalide', + loginResult: 'SUSPENDED', + createAccountResult: 'ERROR', + testType: 'login' + }); + + // LOGIN-06: Utilisateur suspendu - périmètre invalide + if (invalidGeoCodes.length > 0) { + loginTestCases.push({ + id: 'LOGIN-06', + description: 'Suspendu - périmètre invalide', + email: `test.suspended.perimeter@zlv.fr`, + establishmentName: 'Saint-Lô Agglo', + geoCodes: invalidGeoCodes, + perimeterValid: false, + accessLevelValid: true, + structureAccessValid: true, + expectedSuspended: true, + expectedCause: 'perimetre_invalide', + loginResult: 'SUSPENDED', + createAccountResult: 'ERROR', + testType: 'login' + }); + } + + // LOGIN-07: Utilisateur suspendu - multiple causes + loginTestCases.push({ + id: 'LOGIN-07', + description: 'Suspendu - multiple causes', + email: `test.suspended.multiple@zlv.fr`, + establishmentName: 'Saint-Lô Agglo', + geoCodes: validGeoCodes, + perimeterValid: true, + accessLevelValid: true, + structureAccessValid: true, + expectedSuspended: true, + expectedCause: 'droits utilisateur expires, droits structure expires, cgu vides', + loginResult: 'SUSPENDED', + createAccountResult: 'ERROR', + testType: 'login' + }); + + // LOGIN-08: Utilisateur suspendu - niveau accès ET périmètre invalides + if (invalidGeoCodes.length > 0) { + loginTestCases.push({ + id: 'LOGIN-08', + description: 'Suspendu - accès + périmètre invalides', + email: `test.suspended.access.perimeter@zlv.fr`, + establishmentName: 'Eurométropole de Strasbourg', + geoCodes: invalidGeoCodes, + perimeterValid: false, + accessLevelValid: false, + structureAccessValid: true, + expectedSuspended: true, + expectedCause: 'niveau_acces_invalide, perimetre_invalide', + loginResult: 'SUSPENDED', + createAccountResult: 'ERROR', + testType: 'login' + }); + } + + // LOGIN-09: Compte supprimé (deletedAt défini) + // NOTE: Ce cas n'est pas encore implémenté dans les seeds + loginTestCases.push({ + id: 'LOGIN-09', + description: 'Compte supprimé', + email: `test.deleted@zlv.fr`, + establishmentName: 'Eurométropole de Strasbourg', + geoCodes: validGeoCodes, + perimeterValid: true, + accessLevelValid: true, + structureAccessValid: true, + expectedSuspended: false, + expectedCause: null, + loginResult: 'FORBIDDEN', + createAccountResult: 'OK', + testType: 'login' + }); + + // ========================================================================== + // CAS DE TEST CRÉATION DE COMPTE (CREATE ACCOUNT) + // Le prospect existe dans Portail DF, un signup link a été généré + // ========================================================================== + + // CREATE-01: Création compte - tous droits valides + createAccountTestCases.push({ + id: 'CREATE-01', + description: 'Création - tous droits valides', + email: `test.create.valid@zlv.fr`, + establishmentName: 'Test Create - Tous Droits Valides', + geoCodes: validGeoCodes, + perimeterValid: true, + accessLevelValid: true, + structureAccessValid: true, + expectedSuspended: false, + expectedCause: null, + loginResult: 'OK', + createAccountResult: 'OK', + testType: 'create_account' + }); + + // CREATE-02: Création compte - niveau accès invalide (bloqué) + createAccountTestCases.push({ + id: 'CREATE-02', + description: 'Création - niveau accès invalide (BLOQUÉ)', + email: `test.create.invalid.access@zlv.fr`, + establishmentName: 'Test Create - Accès Invalide', + geoCodes: validGeoCodes, + perimeterValid: true, + accessLevelValid: false, + structureAccessValid: true, + expectedSuspended: false, + expectedCause: 'niveau_acces_invalide', + loginResult: 'SUSPENDED', + createAccountResult: 'ERROR', + testType: 'create_account' + }); + + // CREATE-03: Création compte - périmètre invalide (bloqué) + if (invalidGeoCodes.length > 0) { + createAccountTestCases.push({ + id: 'CREATE-03', + description: 'Création - périmètre invalide (BLOQUÉ)', + email: `test.create.invalid.perimeter@zlv.fr`, + establishmentName: 'Test Create - Périmètre Invalide', + geoCodes: invalidGeoCodes, + perimeterValid: false, + accessLevelValid: true, + structureAccessValid: true, + expectedSuspended: false, + expectedCause: 'perimetre_invalide', + loginResult: 'SUSPENDED', + createAccountResult: 'ERROR', + testType: 'create_account' + }); + } + + // CREATE-04: Création compte - accès ET périmètre invalides (bloqué) + if (invalidGeoCodes.length > 0) { + createAccountTestCases.push({ + id: 'CREATE-04', + description: 'Création - accès + périmètre invalides (BLOQUÉ)', + email: `test.create.invalid.both@zlv.fr`, + establishmentName: 'Test Create - Accès + Périmètre Invalides', + geoCodes: invalidGeoCodes, + perimeterValid: false, + accessLevelValid: false, + structureAccessValid: true, + expectedSuspended: false, + expectedCause: 'niveau_acces_invalide, perimetre_invalide', + loginResult: 'SUSPENDED', + createAccountResult: 'ERROR', + testType: 'create_account' + }); + } + + // CREATE-05: Création compte - droits structure expirés + createAccountTestCases.push({ + id: 'CREATE-05', + description: 'Création - droits structure expirés (BLOQUÉ)', + email: `test.create.expired.structure@zlv.fr`, + establishmentName: 'Test Create - Structure Expirée', + geoCodes: validGeoCodes, + perimeterValid: true, + accessLevelValid: true, + structureAccessValid: false, + expectedSuspended: false, + expectedCause: 'droits structure expires', + loginResult: 'SUSPENDED', + createAccountResult: 'ERROR', + testType: 'create_account' + }); + + // CREATE-06: Création compte - CGU non validées + createAccountTestCases.push({ + id: 'CREATE-06', + description: 'Création - CGU non validées (BLOQUÉ)', + email: `test.create.cgu.empty@zlv.fr`, + establishmentName: 'Test Create - CGU Vides', + geoCodes: validGeoCodes, + perimeterValid: true, + accessLevelValid: true, + structureAccessValid: true, + expectedSuspended: false, + expectedCause: 'cgu vides', + loginResult: 'SUSPENDED', + createAccountResult: 'ERROR', + testType: 'create_account' + }); + + // CREATE-07: Création compte - droits utilisateur expirés + createAccountTestCases.push({ + id: 'CREATE-07', + description: 'Création - droits utilisateur expirés (BLOQUÉ)', + email: `test.create.expired.user@zlv.fr`, + establishmentName: 'Test Create - Utilisateur Expiré', + geoCodes: validGeoCodes, + perimeterValid: true, + accessLevelValid: true, + structureAccessValid: true, + expectedSuspended: false, + expectedCause: 'droits utilisateur expires', + loginResult: 'SUSPENDED', + createAccountResult: 'ERROR', + testType: 'create_account' + }); + + return { login: loginTestCases, createAccount: createAccountTestCases }; +} + +// ============================================================================= +// OUTPUT +// ============================================================================= + +function printSummaryTable(loginTestCases: TestCase[], createAccountTestCases: TestCase[]): void { + // ========================================================================== + // TABLEAU DES CAS DE TEST - CONNEXION + // ========================================================================== + console.log('\n' + '='.repeat(130)); + console.log('📊 CAS DE TEST - CONNEXION (utilisateur existant)'); + console.log('='.repeat(130)); + + console.log('\n┌──────────┬─────────────────────────────────────────┬───────────┬───────────┬───────────┬────────────┬────────────┬───────────────────────────────────────┐'); + console.log('│ ID │ Description │ Périmètre │ Niv.Accès │ Structure │ Suspendu │ Résultat │ Cause │'); + console.log('├──────────┼─────────────────────────────────────────┼───────────┼───────────┼───────────┼────────────┼────────────┼───────────────────────────────────────┤'); + + for (const tc of loginTestCases) { + const id = tc.id.padEnd(8); + const desc = tc.description.padEnd(39).substring(0, 39); + const peri = (tc.perimeterValid ? '✅' : '❌').padEnd(9); + const access = (tc.accessLevelValid ? '✅' : '❌').padEnd(9); + const struct = (tc.structureAccessValid ? '✅' : '❌').padEnd(9); + const susp = (tc.expectedSuspended ? '🔴 OUI' : '🟢 NON').padEnd(10); + const result = tc.loginResult.padEnd(10); + const cause = (tc.expectedCause || '-').padEnd(37).substring(0, 37); + + console.log(`│ ${id} │ ${desc} │ ${peri} │ ${access} │ ${struct} │ ${susp} │ ${result} │ ${cause} │`); + } + + console.log('└──────────┴─────────────────────────────────────────┴───────────┴───────────┴───────────┴────────────┴────────────┴───────────────────────────────────────┘'); + + // ========================================================================== + // TABLEAU DES CAS DE TEST - CRÉATION DE COMPTE + // ========================================================================== + console.log('\n' + '='.repeat(130)); + console.log('📊 CAS DE TEST - CRÉATION DE COMPTE (prospect + signup link)'); + console.log('='.repeat(130)); + + console.log('\n┌──────────┬─────────────────────────────────────────┬───────────┬───────────┬───────────┬────────────┬───────────────────────────────────────┐'); + console.log('│ ID │ Description │ Périmètre │ Niv.Accès │ Structure │ Résultat │ Cause │'); + console.log('├──────────┼─────────────────────────────────────────┼───────────┼───────────┼───────────┼────────────┼───────────────────────────────────────┤'); + + for (const tc of createAccountTestCases) { + const id = tc.id.padEnd(8); + const desc = tc.description.padEnd(39).substring(0, 39); + const peri = (tc.perimeterValid ? '✅' : '❌').padEnd(9); + const access = (tc.accessLevelValid ? '✅' : '❌').padEnd(9); + const struct = (tc.structureAccessValid ? '✅' : '❌').padEnd(9); + const result = tc.createAccountResult.padEnd(10); + const cause = (tc.expectedCause || '-').padEnd(37).substring(0, 37); + + console.log(`│ ${id} │ ${desc} │ ${peri} │ ${access} │ ${struct} │ ${result} │ ${cause} │`); + } + + console.log('└──────────┴─────────────────────────────────────────┴───────────┴───────────┴───────────┴────────────┴───────────────────────────────────────┘'); + + console.log(` +📖 LÉGENDE: + - Périmètre: Périmètre géographique correspond à l'établissement ZLV + - Niv.Accès: niveau_acces = 'lovac' OU lovac = true dans le groupe Portail DF + - Structure: acces_lovac de la structure est dans le futur + + CONNEXION: + - Suspendu: L'utilisateur a suspendedAt défini en base + - Résultat: + - OK: Connexion réussie, accès normal + - SUSPENDED: Connexion réussie mais modal de suspension affiché + - FORBIDDEN: Connexion refusée (compte supprimé, HTTP 403) + + CRÉATION DE COMPTE: + - Résultat: + - OK: Compte créé avec succès + - ERROR: Création bloquée (erreur retournée au frontend) + `); +} + +function printSeedCode(loginTestCases: TestCase[], createAccountTestCases: TestCase[], userData: FullUserData): void { + const siren = userData.structure.siret.substring(0, 9); + + console.log('\n' + '='.repeat(130)); + console.log('📝 CODE SEED À COPIER'); + console.log('='.repeat(130)); + + // ========================================================================== + // ÉTABLISSEMENTS + // ========================================================================== + console.log(` +// ============================================================================= +// ÉTABLISSEMENTS DE TEST - À ajouter dans 20240404235442_establishments.ts +// Basé sur: ${userData.email} / Structure: ${userData.structure.nom} +// ============================================================================= + +// SIREN de référence pour les tests +export const SirenTest = '${siren}'; + +// Établissements pour les cas de test de CONNEXION +`); + + // Grouper par nom d'établissement unique pour éviter les doublons + const allTestCases = [...loginTestCases, ...createAccountTestCases]; + const uniqueEstablishments = new Map(); + for (const tc of allTestCases) { + if (!uniqueEstablishments.has(tc.establishmentName)) { + uniqueEstablishments.set(tc.establishmentName, tc); + } + } + + for (const [name, tc] of uniqueEstablishments) { + console.log(` +// Établissement pour: ${tc.id} +export const ${tc.id.replace('-', '')}EstablishmentId = faker.string.uuid(); +await Establishments(knex).insert({ + id: ${tc.id.replace('-', '')}EstablishmentId, + name: '${name}', + siren: Number('${siren}'), + available: true, + localities_geo_code: [${tc.geoCodes.map(c => `'${c}'`).join(', ')}], + kind: 'Commune', + source: 'seed', + updated_at: new Date() +}).onConflict('name').ignore(); +`); + } + + // ========================================================================== + // UTILISATEURS POUR CONNEXION + // ========================================================================== + console.log(` +// ============================================================================= +// UTILISATEURS DE TEST (CONNEXION) - À ajouter dans 20240404235457_users.ts +// Ces utilisateurs existent en base pour tester la connexion +// ============================================================================= +`); + + for (const tc of loginTestCases) { + const suspendedAt = tc.expectedSuspended ? 'now' : 'null'; + const suspendedCause = tc.expectedCause ? `'${tc.expectedCause}'` : 'null'; + const deletedAt = tc.loginResult === 'FORBIDDEN' ? 'now' : 'null'; + + console.log(` +// ${tc.id}: ${tc.description} +createBaseUser({ + email: '${tc.email}', + password: hashedPassword, + firstName: 'Test Login', + lastName: '${tc.description.substring(0, 25)}', + establishmentId: ${tc.id.replace('-', '')}EstablishmentId, + activatedAt: now, + role: UserRole.USUAL, + suspendedAt: ${suspendedAt}, + suspendedCause: ${suspendedCause}, + deletedAt: ${deletedAt} +}),`); + } + + // ========================================================================== + // PROSPECTS ET SIGNUP LINKS POUR CRÉATION DE COMPTE + // ========================================================================== + console.log(` + +// ============================================================================= +// PROSPECTS DE TEST (CRÉATION DE COMPTE) - À ajouter dans un nouveau seed +// Ces prospects sont utilisés pour tester la création de compte +// ============================================================================= + +import { Prospects } from '~/repositories/prospectRepository'; +import { SignupLinks } from '~/repositories/signupLinkRepository'; + +const futureDate = new Date(); +futureDate.setDate(futureDate.getDate() + 7); // Expire dans 7 jours + +// Insertion des prospects +await Prospects(knex).insert([ +`); + + for (const tc of createAccountTestCases) { + console.log(` // ${tc.id}: ${tc.description} + { + email: '${tc.email}', + establishment_siren: Number('${siren}'), + has_account: false, + has_commitment: true, + last_account_request_at: new Date() + },`); + } + + console.log(`]).onConflict('email').ignore(); + +// Insertion des signup links +await SignupLinks(knex).insert([ +`); + + for (const tc of createAccountTestCases) { + const linkId = tc.id.toLowerCase().replace('-', '_'); + console.log(` // ${tc.id}: ${tc.description} + { + id: '${linkId}_signup_link', + prospect_email: '${tc.email}', + expires_at: futureDate + },`); + } + + console.log(`]).onConflict('id').ignore(); +`); + + // ========================================================================== + // INSTRUCTIONS DE TEST + // ========================================================================== + console.log(` + +// ============================================================================= +// 🧪 INSTRUCTIONS DE TEST MANUEL +// ============================================================================= + +/* +TESTS DE CONNEXION: +------------------- +Pour chaque utilisateur LOGIN-XX, effectuer les étapes suivantes: + +1. Aller sur la page de connexion: ${process.env.FRONTEND_URL || 'http://localhost:3000'}/connexion +2. Entrer l'email: test.suspended.xxx@zlv.fr (ou test.strasbourg@zlv.fr pour LOGIN-01) +3. Entrer le mot de passe: (défini par TEST_PASSWORD dans .env) +4. Vérifier le résultat attendu: + - OK: Accès normal au tableau de bord + - SUSPENDED: Connexion RÉUSSIE mais modal de suspension affiché avec la cause appropriée + - FORBIDDEN: Connexion refusée (compte supprimé, HTTP 403) + +TESTS DE CRÉATION DE COMPTE: +---------------------------- +Pour chaque prospect CREATE-XX, effectuer les étapes suivantes: + +1. Accéder au lien de création de compte: + ${process.env.FRONTEND_URL || 'http://localhost:3000'}/inscription/{signup_link_id} + +2. Remplir le formulaire avec: + - Email: test.create.xxx@zlv.fr + - Mot de passe: (votre choix) + +3. Vérifier le résultat attendu: + - OK: Compte créé avec succès, redirection vers le tableau de bord + - ERROR: Message d'erreur affiché, compte non créé + +LIENS DE SIGNUP GÉNÉRÉS: +`); + + for (const tc of createAccountTestCases) { + const linkId = tc.id.toLowerCase().replace('-', '_'); + console.log(` ${tc.id}: ${process.env.FRONTEND_URL || 'http://localhost:3000'}/inscription/${linkId}_signup_link`); + } + + console.log(` +*/ +`); +} + +// ============================================================================= +// MAIN +// ============================================================================= + +async function main(): Promise { + console.log('🔐 Authentification Portail DF...'); + const token = await authenticate(); + if (!token) { + console.error('❌ Échec authentification'); + process.exit(1); + } + console.log('✅ Authentifié\n'); + + const email = process.argv[2] || 'test.strasbourg@zlv.fr'; + console.log(`📧 Récupération données pour: ${email}`); + + const userData = await fetchUserData(token, email); + if (!userData) { + console.error('❌ Utilisateur non trouvé sur Portail DF'); + console.log('\n💡 Cet utilisateur existe peut-être uniquement en base ZLV (seed)'); + console.log(' Essayez avec un email réel de Portail DF'); + process.exit(1); + } + + console.log(`\n📦 Structure: ${userData.structure.nom}`); + console.log(` SIREN: ${userData.structure.siret.substring(0, 9)}`); + console.log(` acces_lovac: ${userData.structure.acces_lovac || 'NULL'}`); + + if (userData.group) { + console.log(`👥 Groupe: ${userData.group.nom}`); + console.log(` niveau_acces: ${userData.group.niveau_acces}`); + console.log(` lovac: ${userData.group.lovac}`); + } + + if (userData.perimeter) { + console.log(`🗺️ Périmètre:`); + console.log(` fr_entiere: ${userData.perimeter.fr_entiere}`); + if (userData.perimeter.reg.length) console.log(` reg: [${userData.perimeter.reg.join(', ')}]`); + if (userData.perimeter.dep.length) console.log(` dep: [${userData.perimeter.dep.join(', ')}]`); + if (userData.perimeter.epci.length) console.log(` epci: [${userData.perimeter.epci.join(', ')}]`); + if (userData.perimeter.comm.length) console.log(` comm: [${userData.perimeter.comm.join(', ')}]`); + } + + const { login: loginTestCases, createAccount: createAccountTestCases } = generateTestCases(userData); + + console.log(`\n📊 Cas de test générés:`); + console.log(` - Connexion: ${loginTestCases.length} cas`); + console.log(` - Création de compte: ${createAccountTestCases.length} cas`); + + printSummaryTable(loginTestCases, createAccountTestCases); + printSeedCode(loginTestCases, createAccountTestCases, userData); + + console.log('\n' + '='.repeat(130)); + console.log('✅ Génération terminée'); + console.log('='.repeat(130)); +} + +main().catch(console.error); diff --git a/server/src/scripts/test-portaildf-rights/test-portaildf-rights.ts b/server/src/scripts/test-portaildf-rights/test-portaildf-rights.ts new file mode 100644 index 0000000000..4e7978f608 --- /dev/null +++ b/server/src/scripts/test-portaildf-rights/test-portaildf-rights.ts @@ -0,0 +1,388 @@ +/** + * Script de test temporaire pour vérifier les droits Portail DF + * + * Ce script teste les différents cas d'erreurs possibles : + * - Connexion avec utilisateur suspendu (différentes causes) + * - Création de compte avec droits invalides + * - Vérification du niveau d'accès LOVAC + * - Vérification du périmètre géographique + * + * Usage: + * npx tsx server/src/scripts/test-portaildf-rights/test-portaildf-rights.ts + * + * Environnement requis: + * DATABASE_URL - URL de connexion à la base de données staging + * CEREMA_USERNAME - Identifiant Portail DF + * CEREMA_PASSWORD - Mot de passe Portail DF + * CEREMA_API - URL de l'API Portail DF (https://portaildf.cerema.fr) + */ + +import 'dotenv/config'; + +// Configuration manuelle si les variables d'environnement ne sont pas chargées +const config = { + cerema: { + api: process.env.CEREMA_API || 'https://portaildf.cerema.fr', + username: process.env.CEREMA_USERNAME || '', + password: process.env.CEREMA_PASSWORD || '' + } +}; + +interface CeremaGroup { + id_groupe: number; + nom: string; + structure: number; + perimetre: number; + niveau_acces: string; + df_ano: boolean; + df_non_ano: boolean; + lovac: boolean; +} + +interface CeremaPerimeter { + perimetre_id: number; + origine: string; + fr_entiere: boolean; + reg: string[]; + dep: string[]; + epci: string[]; + comm: string[]; +} + +interface CeremaUserResult { + email: string; + structure: number; + groupe: number; + structureData?: { + siret: string; + nom: string; + acces_lovac: string | null; + }; + groupData?: CeremaGroup; + perimeterData?: CeremaPerimeter; +} + +interface PortailDFApiResponse { + count: number; + results: Array<{ + email: string; + structure: number; + groupe: number; + }>; +} + +// ============================================================================= +// PORTAIL DF API FUNCTIONS +// ============================================================================= + +async function authenticatePortailDF(): Promise { + console.log('\n🔐 Authentification Portail DF...'); + + if (!config.cerema.username || !config.cerema.password) { + console.error('❌ CEREMA_USERNAME et CEREMA_PASSWORD sont requis'); + return null; + } + + try { + const formData = new FormData(); + formData.append('username', config.cerema.username); + formData.append('password', config.cerema.password); + + const response = await fetch(`${config.cerema.api}/api/api-token-auth/`, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + console.error(`❌ Échec authentification: ${response.status}`); + return null; + } + + const data = await response.json() as { token: string }; + console.log('✅ Authentification réussie'); + return data.token; + } catch (error) { + console.error('❌ Erreur authentification:', error); + return null; + } +} + +async function fetchFromPortailDF(token: string, endpoint: string): Promise { + try { + const response = await fetch(`${config.cerema.api}${endpoint}`, { + method: 'GET', + headers: { + 'Authorization': `Token ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + console.warn(` ⚠️ Échec ${endpoint}: ${response.status}`); + return null; + } + + return await response.json() as T; + } catch (error) { + console.error(` ❌ Erreur ${endpoint}:`, error); + return null; + } +} + +async function getUserInfoFromPortailDF(token: string, email: string): Promise { + console.log(`\n📧 Recherche utilisateur: ${email}`); + + const userResponse = await fetchFromPortailDF( + token, + `/api/utilisateurs/?email=${encodeURIComponent(email)}` + ); + + if (!userResponse || userResponse.count === 0) { + console.log(' ❌ Aucun utilisateur trouvé sur Portail DF'); + return []; + } + + console.log(` ✅ ${userResponse.count} compte(s) trouvé(s)`); + + const results: CeremaUserResult[] = []; + + for (const user of userResponse.results) { + const result: CeremaUserResult = { + email: user.email, + structure: user.structure, + groupe: user.groupe + }; + + // Fetch structure data + console.log(` 📦 Structure ${user.structure}...`); + const structureData = await fetchFromPortailDF<{ + siret: string; + nom: string; + acces_lovac: string | null; + }>(token, `/api/structures/${user.structure}/`); + + if (structureData) { + result.structureData = structureData; + console.log(` SIREN: ${structureData.siret.substring(0, 9)}`); + console.log(` Nom: ${structureData.nom}`); + console.log(` acces_lovac: ${structureData.acces_lovac || 'NULL'}`); + + // Check if acces_lovac is valid + if (structureData.acces_lovac) { + const accessDate = new Date(structureData.acces_lovac); + const now = new Date(); + const isValid = accessDate > now; + console.log(` Date valide: ${isValid ? '✅ OUI' : '❌ NON (expirée)'}`); + } + } + + // Fetch group data + if (user.groupe) { + console.log(` 👥 Groupe ${user.groupe}...`); + const groupData = await fetchFromPortailDF( + token, + `/api/groupes/${user.groupe}/` + ); + + if (groupData) { + result.groupData = groupData; + console.log(` Nom: ${groupData.nom}`); + console.log(` niveau_acces: ${groupData.niveau_acces}`); + console.log(` lovac: ${groupData.lovac}`); + console.log(` df_ano: ${groupData.df_ano}`); + console.log(` df_non_ano: ${groupData.df_non_ano}`); + + // Check LOVAC access level + const hasLovacAccess = groupData.niveau_acces === 'lovac' || groupData.lovac === true; + console.log(` Accès LOVAC valide: ${hasLovacAccess ? '✅ OUI' : '❌ NON'}`); + + // Fetch perimeter + if (groupData.perimetre) { + console.log(` 🗺️ Périmètre ${groupData.perimetre}...`); + const perimeterData = await fetchFromPortailDF( + token, + `/api/perimetres/${groupData.perimetre}/` + ); + + if (perimeterData) { + result.perimeterData = perimeterData; + console.log(` fr_entiere: ${perimeterData.fr_entiere}`); + console.log(` reg: [${perimeterData.reg.join(', ')}]`); + console.log(` dep: [${perimeterData.dep.join(', ')}]`); + console.log(` epci: [${perimeterData.epci.join(', ')}]`); + console.log(` comm: [${perimeterData.comm.join(', ')}]`); + } + } + } + } else { + console.log(' ⚠️ Aucun groupe associé'); + } + + results.push(result); + } + + return results; +} + +// ============================================================================= +// TEST CASES +// ============================================================================= + +interface TestCase { + name: string; + email: string; + description: string; + expectedIssues?: string[]; +} + +const TEST_EMAILS: TestCase[] = [ + // Utilisateurs de test existants en staging/review + { + name: 'Test Strasbourg', + email: 'test.strasbourg@zlv.fr', + description: 'Utilisateur de test standard (seed)' + }, + { + name: 'Test Saint-Lô', + email: 'test.saintlo@zlv.fr', + description: 'Utilisateur de test standard (seed)' + }, + // Ajouter ici des emails réels pour tester + // { + // name: 'Utilisateur réel 1', + // email: 'email@example.fr', + // description: 'Test avec un utilisateur réel' + // }, +]; + +// ============================================================================= +// ANALYSIS FUNCTIONS +// ============================================================================= + +function analyzeUserRights(results: CeremaUserResult[]): void { + console.log('\n' + '='.repeat(80)); + console.log('📊 ANALYSE DES DROITS'); + console.log('='.repeat(80)); + + for (const result of results) { + console.log(`\n📧 ${result.email}`); + + const issues: string[] = []; + + // Check structure LOVAC access + if (!result.structureData) { + issues.push('Structure non trouvée'); + } else if (!result.structureData.acces_lovac) { + issues.push('droits structure expires (acces_lovac NULL)'); + } else { + const accessDate = new Date(result.structureData.acces_lovac); + if (accessDate <= new Date()) { + issues.push('droits structure expires (date expirée)'); + } + } + + // Check group + if (!result.groupData) { + issues.push('Aucun groupe associé'); + } else { + // Check LOVAC access level + const hasLovacAccess = result.groupData.niveau_acces === 'lovac' || result.groupData.lovac === true; + if (!hasLovacAccess) { + issues.push('niveau_acces_invalide'); + } + + // Check perimeter + if (!result.perimeterData) { + issues.push('Périmètre non trouvé'); + } else if (!result.perimeterData.fr_entiere && + result.perimeterData.reg.length === 0 && + result.perimeterData.dep.length === 0 && + result.perimeterData.epci.length === 0 && + result.perimeterData.comm.length === 0) { + issues.push('perimetre_invalide (vide)'); + } + } + + if (issues.length === 0) { + console.log(' ✅ Tous les droits sont valides'); + } else { + console.log(' ❌ Problèmes détectés:'); + issues.forEach(issue => console.log(` - ${issue}`)); + console.log(` 💡 Cause de suspension potentielle: "${issues.join(', ')}"`); + } + } +} + +// ============================================================================= +// MAIN +// ============================================================================= + +async function main(): Promise { + console.log('='.repeat(80)); + console.log('🧪 TEST DES DROITS PORTAIL DF'); + console.log('='.repeat(80)); + console.log(`API: ${config.cerema.api}`); + console.log(`Username: ${config.cerema.username ? '***' : 'NON DÉFINI'}`); + + // Authenticate + const token = await authenticatePortailDF(); + if (!token) { + console.error('\n❌ Impossible de continuer sans authentification'); + process.exit(1); + } + + // Get email from command line argument if provided + const customEmail = process.argv[2]; + + if (customEmail) { + console.log(`\n🎯 Test avec email personnalisé: ${customEmail}`); + const results = await getUserInfoFromPortailDF(token, customEmail); + analyzeUserRights(results); + } else { + // Test all predefined emails + console.log('\n📋 Emails à tester:'); + TEST_EMAILS.forEach(tc => console.log(` - ${tc.email} (${tc.description})`)); + + for (const testCase of TEST_EMAILS) { + console.log('\n' + '-'.repeat(80)); + console.log(`🧪 ${testCase.name}: ${testCase.description}`); + + const results = await getUserInfoFromPortailDF(token, testCase.email); + + if (results.length === 0) { + console.log(' ⚠️ Utilisateur non trouvé sur Portail DF'); + console.log(' 💡 Cet utilisateur existe peut-être uniquement en base ZLV (seed)'); + } else { + analyzeUserRights(results); + } + } + } + + console.log('\n' + '='.repeat(80)); + console.log('✅ Tests terminés'); + console.log('='.repeat(80)); + + // Summary of suspension causes + console.log(` +📖 RAPPEL DES CAUSES DE SUSPENSION: + + Existantes: + - "droits utilisateur expires" → Droits utilisateur expirés sur Portail DF + - "droits structure expires" → acces_lovac NULL ou date expirée + - "cgu vides" → CGU non validées sur Portail DF + + Nouvelles (à implémenter côté sync): + - "niveau_acces_invalide" → niveau_acces != 'lovac' ET lovac != true + - "perimetre_invalide" → Périmètre géographique ne couvre pas l'établissement + +📝 UTILISATION: + + # Tester un email spécifique: + npx tsx server/src/scripts/test-portaildf-rights/test-portaildf-rights.ts email@example.fr + + # Tester les emails prédéfinis: + npx tsx server/src/scripts/test-portaildf-rights/test-portaildf-rights.ts +`); +} + +main().catch(console.error); diff --git a/server/src/services/ceremaService/ceremaService.ts b/server/src/services/ceremaService/ceremaService.ts index e87f684cdc..1d87a23369 100644 --- a/server/src/services/ceremaService/ceremaService.ts +++ b/server/src/services/ceremaService/ceremaService.ts @@ -1,69 +1,157 @@ -import { CeremaUser, ConsultUserService } from './consultUserService'; +import { + CeremaGroup, + CeremaPerimeter, + CeremaUser, + ConsultUserService +} from './consultUserService'; import config from '~/infra/config'; import { logger } from '~/infra/logger'; -export class CeremaService implements ConsultUserService { +/** + * Check if a LOVAC access date is valid (in the future). + */ +function isLovacAccessValid(accesLovac: string | null): boolean { + if (!accesLovac) { + return false; + } + + try { + const accessDate = new Date(accesLovac); + const now = new Date(); + return accessDate > now; + } catch { + return false; + } +} + +/** + * Make an authenticated API call to Portail DF + */ +async function fetchPortailDF( + token: string, + endpoint: string +): Promise { + try { + const response = await fetch(`${config.cerema.api}${endpoint}`, { + method: 'GET', + headers: { + Authorization: `Token ${token}`, + 'Content-Type': 'application/json' + } + }); + if (!response.ok) { + logger.warn('Failed to fetch from Portail DF', { + endpoint, + status: response.status + }); + return null; + } + + return (await response.json()) as T; + } catch (error) { + logger.error('Error fetching from Portail DF', { endpoint, error }); + return null; + } +} + +export class CeremaService implements ConsultUserService { async consultUsers(email: string): Promise { try { + // Authenticate with Portail DF API using multipart/form-data + const formData = new FormData(); + formData.append('username', config.cerema.username); + formData.append('password', config.cerema.password); const authResponse = await fetch( `${config.cerema.api}/api/api-token-auth/`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - username: config.cerema.username, - password: config.cerema.password, - }), + body: formData } ); + + if (!authResponse.ok) { + logger.error('Failed to authenticate with Portail DF API', { + status: authResponse.status + }); + return []; + } + const { token }: any = await authResponse.json(); + const userResponse = await fetch( - `${config.cerema.api}/api/utilisateurs?email=${email}`, + `${config.cerema.api}/api/utilisateurs/?email=${encodeURIComponent(email)}`, { method: 'GET', headers: { Authorization: `Token ${token}`, - 'Content-Type': 'application/json', - }, - }, + 'Content-Type': 'application/json' + } + } ); + const userContent: any = await userResponse.json(); if (userResponse.status !== 200) { throw userContent.detail; } if (userContent) { - const users = await Promise.all(userContent.results.map( - async (user: { email: any; structure: number; }) => { - const establishmentResponse = await fetch( - `${config.cerema.api}/api/structures/${user.structure}`, - { - method: 'GET', - headers: { - Authorization: `Token ${token}`, - 'Content-Type': 'application/json', - }, - }, - ); - - const establishmentContent: any = await establishmentResponse.json(); - if (establishmentResponse.status !== 200) { - throw establishmentContent.detail; - } + const users = await Promise.all( + userContent.results.map( + async (user: { email: any; structure: number; groupe: number }) => { + const establishmentResponse = await fetch( + `${config.cerema.api}/api/structures/${user.structure}/`, + { + method: 'GET', + headers: { + Authorization: `Token ${token}`, + 'Content-Type': 'application/json' + } + } + ); + + const establishmentContent: any = + await establishmentResponse.json(); + if (establishmentResponse.status !== 200) { + throw establishmentContent.detail; + } + + // Fetch group info if available + let group: CeremaGroup | undefined; + let perimeter: CeremaPerimeter | undefined; - const u = { - email: user.email, - establishmentSiren: parseInt(establishmentContent.siret.substring(0, 9)), - hasAccount: true, - hasCommitment: establishmentContent.acces_lovac !== null, - }; - return u; - })); + if (user.groupe) { + group = await fetchPortailDF( + token, + `/api/groupes/${user.groupe}/` + ) ?? undefined; + + // Fetch perimeter if group has one + if (group?.perimetre) { + perimeter = await fetchPortailDF( + token, + `/api/perimetres/${group.perimetre}/` + ) ?? undefined; + } + } + + const u: CeremaUser = { + email: user.email, + establishmentSiren: establishmentContent.siret.substring(0, 9), + hasAccount: true, + // Check that acces_lovac is not null AND is a valid future date + hasCommitment: isLovacAccessValid( + establishmentContent.acces_lovac + ), + group, + perimeter + }; + return u; + } + ) + ); return users; } return []; diff --git a/server/src/services/ceremaService/consultUserService.ts b/server/src/services/ceremaService/consultUserService.ts index 200f3451de..fe6e949c8c 100644 --- a/server/src/services/ceremaService/consultUserService.ts +++ b/server/src/services/ceremaService/consultUserService.ts @@ -1,10 +1,43 @@ import { Establishment1 } from '~/infra/database/seeds/test/20240405011849_establishments'; +/** + * Portail DF perimeter definition + * Defines the geographic scope of user access + */ +export interface CeremaPerimeter { + perimetre_id: number; + origine: string; + fr_entiere: boolean; + reg: string[]; // Region codes + dep: string[]; // Department codes (2-3 chars) + epci: string[]; // EPCI SIREN codes (9 chars) + comm: string[]; // Commune INSEE codes (5 chars) +} + +/** + * Portail DF group definition + * Defines user access level and perimeter + */ +export interface CeremaGroup { + id_groupe: number; + nom: string; + structure: number; + perimetre: number; + niveau_acces: string; // 'lovac', 'df', etc. + df_ano: boolean; + df_non_ano: boolean; + lovac: boolean; +} + export interface CeremaUser { email: string; establishmentSiren: string; hasAccount: boolean; hasCommitment: boolean; + /** User's group info from Portail DF */ + group?: CeremaGroup; + /** User's perimeter info from Portail DF */ + perimeter?: CeremaPerimeter; } export const TEST_ACCOUNTS: ReadonlyArray = [ diff --git a/server/src/services/ceremaService/mockCeremaService.ts b/server/src/services/ceremaService/mockCeremaService.ts index ced7c26ec5..ecf4bd1cd9 100644 --- a/server/src/services/ceremaService/mockCeremaService.ts +++ b/server/src/services/ceremaService/mockCeremaService.ts @@ -6,17 +6,24 @@ import { import { SirenStrasbourg } from '~/infra/database/seeds/development/20240404235442_establishments'; +// Special SIREN that matches any establishment in tests +export const MOCK_ANY_SIREN = '*'; + export class MockCeremaService implements ConsultUserService { async consultUsers(email: string): Promise { const testAccount = getTestAccount(email); - return [testAccount ?? defaultOK(email)]; + if (testAccount) { + return [testAccount]; + } + // Return two entries: one with Strasbourg SIREN (for seed data) and one with wildcard (for any generated data) + return [defaultOK(email, SirenStrasbourg), defaultOK(email, MOCK_ANY_SIREN)]; } } -function defaultOK(email: string): CeremaUser { +function defaultOK(email: string, siren: string): CeremaUser { return { email, - establishmentSiren: SirenStrasbourg, + establishmentSiren: siren, hasAccount: true, hasCommitment: true }; diff --git a/server/src/services/ceremaService/perimeterService.ts b/server/src/services/ceremaService/perimeterService.ts new file mode 100644 index 0000000000..34e911b4ad --- /dev/null +++ b/server/src/services/ceremaService/perimeterService.ts @@ -0,0 +1,246 @@ +import { CeremaPerimeter, CeremaGroup, CeremaUser } from './consultUserService'; +import { logger } from '~/infra/logger'; + +/** + * Result of access rights verification + */ +export interface AccessRightsResult { + isValid: boolean; + errors: AccessRightsError[]; +} + +export type AccessRightsError = + | 'niveau_acces_invalide' + | 'perimetre_invalide' + | 'groupe_manquant'; + +/** + * Extract department code from a commune INSEE code + * INSEE codes are 5 digits: first 2 (or 3 for DOM-TOM) are department code + * Examples: + * - 75056 (Paris) -> 75 + * - 13055 (Marseille) -> 13 + * - 97105 (Basse-Terre, Guadeloupe) -> 971 + */ +function getDepartmentFromCommune(communeCode: string): string { + if (!communeCode || communeCode.length < 2) { + return ''; + } + + // DOM-TOM departments start with 97 and have 3-digit codes + if (communeCode.startsWith('97')) { + return communeCode.substring(0, 3); + } + + // Corsica: 2A and 2B + if (communeCode.startsWith('2A') || communeCode.startsWith('2B')) { + return communeCode.substring(0, 2); + } + + // Metropolitan France: 2-digit department codes + return communeCode.substring(0, 2); +} + +/** + * Extract region code from a department code + * This is a simplified mapping - in a real scenario, you might want to use a lookup table + */ +function getRegionFromDepartment(departmentCode: string): string | null { + // Map of department to region codes + // Source: https://www.insee.fr/fr/information/2028040 + const departmentToRegion: Record = { + // Auvergne-Rhône-Alpes (84) + '01': '84', '03': '84', '07': '84', '15': '84', '26': '84', '38': '84', + '42': '84', '43': '84', '63': '84', '69': '84', '73': '84', '74': '84', + // Bourgogne-Franche-Comté (27) + '21': '27', '25': '27', '39': '27', '58': '27', '70': '27', '71': '27', + '89': '27', '90': '27', + // Bretagne (53) + '22': '53', '29': '53', '35': '53', '56': '53', + // Centre-Val de Loire (24) + '18': '24', '28': '24', '36': '24', '37': '24', '41': '24', '45': '24', + // Corse (94) + '2A': '94', '2B': '94', + // Grand Est (44) + '08': '44', '10': '44', '51': '44', '52': '44', '54': '44', '55': '44', + '57': '44', '67': '44', '68': '44', '88': '44', + // Hauts-de-France (32) + '02': '32', '59': '32', '60': '32', '62': '32', '80': '32', + // Île-de-France (11) + '75': '11', '77': '11', '78': '11', '91': '11', '92': '11', '93': '11', + '94': '11', '95': '11', + // Normandie (28) + '14': '28', '27': '28', '50': '28', '61': '28', '76': '28', + // Nouvelle-Aquitaine (75) + '16': '75', '17': '75', '19': '75', '23': '75', '24': '75', '33': '75', + '40': '75', '47': '75', '64': '75', '79': '75', '86': '75', '87': '75', + // Occitanie (76) + '09': '76', '11': '76', '12': '76', '30': '76', '31': '76', '32': '76', + '34': '76', '46': '76', '48': '76', '65': '76', '66': '76', '81': '76', '82': '76', + // Pays de la Loire (52) + '44': '52', '49': '52', '53': '52', '72': '52', '85': '52', + // Provence-Alpes-Côte d'Azur (93) + '04': '93', '05': '93', '06': '93', '13': '93', '83': '93', '84': '93', + // DOM-TOM + '971': '01', // Guadeloupe + '972': '02', // Martinique + '973': '03', // Guyane + '974': '04', // La Réunion + '976': '06', // Mayotte + }; + + return departmentToRegion[departmentCode] || null; +} + +/** + * Check if a commune is within the given perimeter + */ +function isCommuneInPerimeter( + communeCode: string, + perimeter: CeremaPerimeter +): boolean { + // France entière = access to everything + if (perimeter.fr_entiere) { + return true; + } + + // Direct commune match + if (perimeter.comm.includes(communeCode)) { + return true; + } + + // Department match + const departmentCode = getDepartmentFromCommune(communeCode); + if (departmentCode && perimeter.dep.includes(departmentCode)) { + return true; + } + + // Region match + const regionCode = getRegionFromDepartment(departmentCode); + if (regionCode && perimeter.reg.includes(regionCode)) { + return true; + } + + // EPCI match - EPCI codes in perimeter are SIREN codes (9 digits) + // We cannot match EPCI directly with commune code without a mapping table + // For now, we'll skip EPCI matching as it requires additional data + // The EPCI check will be considered as "not blocking" if other checks pass + + return false; +} + +/** + * Check if user has valid LOVAC access level + */ +function hasValidLovacAccessLevel(group: CeremaGroup): boolean { + // Check niveau_acces field - must be 'lovac' for full access + if (group.niveau_acces === 'lovac') { + return true; + } + + // Also check the lovac boolean flag + if (group.lovac === true) { + return true; + } + + return false; +} + +/** + * Verify if user has valid access rights for the given establishment geo codes + * + * @param ceremaUser - User info from Portail DF + * @param establishmentGeoCodes - All communes of the establishment + * @param establishmentSiren - Optional SIREN of the establishment (for EPCI perimeter check) + */ +export function verifyAccessRights( + ceremaUser: CeremaUser, + establishmentGeoCodes: string[], + establishmentSiren?: string +): AccessRightsResult { + const errors: AccessRightsError[] = []; + + // Check if group info is available + if (!ceremaUser.group) { + logger.warn('User has no group info from Portail DF', { + email: ceremaUser.email + }); + // No group info means we cannot verify access level or perimeter + // This is a soft error - we allow access but log it + return { isValid: true, errors: [] }; + } + + // Check LOVAC access level + if (!hasValidLovacAccessLevel(ceremaUser.group)) { + logger.warn('User does not have LOVAC access level', { + email: ceremaUser.email, + niveauAcces: ceremaUser.group.niveau_acces, + lovac: ceremaUser.group.lovac + }); + errors.push('niveau_acces_invalide'); + } + + // Check perimeter + if (ceremaUser.perimeter) { + // Check if user has any commune/department/region restrictions + const hasGeoCodesRestriction = ceremaUser.perimeter.comm && ceremaUser.perimeter.comm.length > 0; + const hasDepartmentsRestriction = ceremaUser.perimeter.dep && ceremaUser.perimeter.dep.length > 0; + const hasRegionsRestriction = ceremaUser.perimeter.reg && ceremaUser.perimeter.reg.length > 0; + const hasGeoRestriction = hasGeoCodesRestriction || hasDepartmentsRestriction || hasRegionsRestriction; + + // Check EPCI perimeter - if establishment SIREN matches an EPCI in perimeter + // AND no more restrictive geo constraints, access is valid + // Note: Convert SIREN to string for comparison since epci array contains strings + const sirenStr = String(establishmentSiren); + const hasEpciAccess = + !hasGeoRestriction && + establishmentSiren && + ceremaUser.perimeter.epci && + ceremaUser.perimeter.epci.length > 0 && + ceremaUser.perimeter.epci.includes(sirenStr); + + if (hasEpciAccess) { + // EPCI match with no geo restriction - user has access to entire establishment + logger.debug('User has EPCI perimeter matching establishment (no geo restriction)', { + email: ceremaUser.email, + establishmentSiren, + perimeterEpci: ceremaUser.perimeter.epci + }); + } else { + // Check if at least one establishment commune is within the perimeter + const hasValidPerimeter = establishmentGeoCodes.some((geoCode) => + isCommuneInPerimeter(geoCode, ceremaUser.perimeter!) + ); + + if (!hasValidPerimeter) { + logger.warn('User perimeter does not cover establishment', { + email: ceremaUser.email, + establishmentSiren, + establishmentGeoCodes: establishmentGeoCodes.slice(0, 10), // Log first 10 for brevity + perimeter: ceremaUser.perimeter, + hasGeoRestriction + }); + errors.push('perimetre_invalide'); + } + } + } else if (ceremaUser.group.perimetre) { + // Group has a perimeter ID but we couldn't fetch it + logger.warn('Could not fetch user perimeter from Portail DF', { + email: ceremaUser.email, + perimetreId: ceremaUser.group.perimetre + }); + // This is a soft error - we allow access but log it + } + + return { + isValid: errors.length === 0, + errors + }; +} + +/** + * Convert access rights errors to a suspension cause string + */ +export function accessErrorsToSuspensionCause(errors: AccessRightsError[]): string { + return errors.join(', '); +} diff --git a/server/src/test/setup-env.ts b/server/src/test/setup-env.ts index c262fc10c7..aafd43203d 100644 --- a/server/src/test/setup-env.ts +++ b/server/src/test/setup-env.ts @@ -1,5 +1,6 @@ process.env.NODE_ENV = 'test'; process.env.AUTH_SECRET = 'secret'; +process.env.CEREMA_ENABLED = 'false'; process.env.SYSTEM_ACCOUNT = `lovac-${new Date() .toISOString() .substring(0, 19)}@test.test`; diff --git a/server/src/types/express-jwt/index.d.ts b/server/src/types/express-jwt/index.d.ts index e7425f1db9..5e144efe2c 100644 --- a/server/src/types/express-jwt/index.d.ts +++ b/server/src/types/express-jwt/index.d.ts @@ -4,6 +4,7 @@ import { MarkRequired } from 'ts-essentials'; import { TokenPayload, UserApi } from '~/models/UserApi'; import { EstablishmentApi } from '~/models/EstablishmentApi'; +import { UserPerimeterApi } from '~/models/UserPerimeterApi'; declare global { namespace Express { @@ -11,6 +12,12 @@ declare global { auth?: jwt.JwtPayload & TokenPayload; establishment?: EstablishmentApi; user?: UserApi; + userPerimeter?: UserPerimeterApi | null; + /** + * GeoCodes filtered by user perimeter (intersection of establishment geoCodes and user perimeter). + * Use this instead of establishment.geoCodes for filtering data. + */ + effectiveGeoCodes?: string[]; } } } @@ -23,6 +30,6 @@ declare module 'express-jwt' { RequestQuery = any > = MarkRequired< express.Request, - 'auth' | 'establishment' | 'user' + 'auth' | 'establishment' | 'user' | 'effectiveGeoCodes' >; }