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'
>;
}