feat(r6): R6.6 — Demandeur restreint à son site

Trouvé en recette : GET /assets/options n'avait aucun filtre — tout
Demandeur voyait le parc complet dans le sélecteur d'équipement de
"Nouvelle demande", sur le web ET le mobile (même endpoint partagé).
Risque réel : signaler accidentellement une panne sur un ascenseur qu'on
ne gère pas.

Corrigé à la racine, sur les deux plateformes à la fois :

- Relation many-to-many User↔Location (assignedSites/assignedUsers,
  migration r6_demandeur_sites, même style que Team.members) — vide =
  aucune restriction, comportement historique inchangé pour tous les
  rôles sauf un Demandeur affecté à un site.
- AssetsService.allowedLocationIds(user) : sites + zones filles
  autorisés, ou null si aucune restriction — réutilisée par options()
  ET par RequestsService.create (défense en profondeur : un assetId
  soumis directement hors périmètre est rejeté, 400).
- UsersService : assertTopLevelSites (un Demandeur est affecté à un
  site, jamais une zone) ; invite()/update() branchent locationIds
  (remplace l'affectation, comme teamIds).
- Web (personnes.tsx) : ModaleInvitation affiche les sites à cocher pour
  un rôle Demandeur ; colonne "Sites" éditable via une modale dédiée.
- Mobile (formulaire-demande.tsx) : bouton "Scanner l'étiquette" en
  raccourci — résout uniquement contre les options déjà chargées (déjà
  filtrées), jamais de repli sur le parc complet qui annulerait la
  restriction. Aucun changement à useAssetOptions() : le filtrage
  serveur profite automatiquement au formulaire mobile.
- Seed : Karim Doukkali (démo) rattaché à Tour Atlas.

Bug trouvé en vérification avant tout commit : create() comparait
allowed.includes(dto.assetId), mais allowed est une liste d'ids de
sites/zones, pas d'ids d'appareils — aurait rejeté à tort tout
signalement d'un Demandeur affecté, y compris dans son propre périmètre.
Corrigé (comparaison sur asset.locationId) ; méthode renommée
allowedAssetIds → allowedLocationIds pour que le nom dise ce qu'elle
retourne.

exploitation.e2e-spec.ts mis à jour (A1/C1 → A2/B1, dans le site de
Karim — sinon rejetés par la nouvelle règle, comportement voulu).
79/80 tests API verts, le seul échec (documents-analytics, monthCost)
est le flake calendaire déjà identifié cette session, sans rapport.
Typecheck/tests/lint verts sur les 4 paquets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-08-02 20:22:50 +01:00
parent 1ae0529a6a
commit c9168ece16
17 changed files with 472 additions and 17 deletions

View File

@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "_LocationToUser" (
"A" UUID NOT NULL,
"B" UUID NOT NULL,
CONSTRAINT "_LocationToUser_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE INDEX "_LocationToUser_B_index" ON "_LocationToUser"("B");
-- AddForeignKey
ALTER TABLE "_LocationToUser" ADD CONSTRAINT "_LocationToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_LocationToUser" ADD CONSTRAINT "_LocationToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -49,6 +49,8 @@ model User {
activationToken String? @unique
activationExpiresAt DateTime?
teams Team[]
// R6.6 — sites autorisés en signalement (Demandeur) ; vide = aucune restriction
assignedSites Location[]
// R2 — exploitation
workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees")
workOrdersCreated WorkOrder[] @relation("WorkOrderCreator")
@@ -111,6 +113,7 @@ model Location {
partnerId String? @db.Uuid
partner Partner? @relation(fields: [partnerId], references: [id])
assets Asset[]
assignedUsers User[] // reverse de User.assignedSites (R6.6)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -462,6 +462,19 @@ async function seedExploitation(prisma: PrismaClient): Promise<void> {
const karim = await parEmail('demandeur@demo.siop.ma');
const annee = new Date().getFullYear();
// R6.6 — Karim (Demandeur) est rattaché à Tour Atlas (cohérent avec le
// guardianName déjà seedé pour ce site) : son signalement se limite à ce
// parc, ses demandes historiques sur d'autres sites restent visibles.
const tourAtlas = await prisma.location.findFirst({
where: { name: 'Tour Atlas', parentId: null },
});
if (tourAtlas) {
await prisma.user.update({
where: { id: karim },
data: { assignedSites: { connect: [{ id: tourAtlas.id }] } },
});
}
const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map(
(t) => t.label,
);

View File

@@ -17,6 +17,7 @@ import {
type AssetCreate,
type AssetUpdate,
} from '@siop/shared';
import { type AuthenticatedUser, CurrentUser } from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { AssetsService } from './assets.service';
@@ -32,10 +33,11 @@ export class AssetsController {
}
/** Avant ':id' (ordre des routes) — authentification seule : le demandeur
* doit pouvoir désigner l'appareil qu'il signale. */
* doit pouvoir désigner l'appareil qu'il signale (filtré à son site s'il
* en a un, R6.6). */
@Get('options')
options() {
return this.assetsService.options();
options(@CurrentUser() user: AuthenticatedUser) {
return this.assetsService.options(user);
}
@Get(':id')

View File

@@ -15,6 +15,7 @@ import type {
AssetsResponse,
AssetUpdate,
} from '@siop/shared';
import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { PrismaService } from '../prisma/prisma.service';
const assetInclude = {
@@ -29,9 +30,30 @@ type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
export class AssetsService {
constructor(private readonly prisma: PrismaService) {}
/** Options minimales pour le signalement — ouvert à tout rôle authentifié. */
async options(): Promise<AssetOptionsResponse> {
/** Sites + zones autorisés pour le signalement de `user`, ou `null` si aucune
* restriction (comportement historique — tous les rôles sauf un Demandeur
* affecté à un site, R6.6). Réutilisée par `options()` ET par
* `RequestsService.create` pour que les deux filtres ne divergent jamais. */
async allowedLocationIds(user: AuthenticatedUser): Promise<string[] | null> {
const me = await this.prisma.user.findUnique({
where: { id: user.userId },
select: { assignedSites: { select: { id: true } } },
});
const siteIds = (me?.assignedSites ?? []).map((s) => s.id);
if (siteIds.length === 0) return null;
const zones = await this.prisma.location.findMany({
where: { parentId: { in: siteIds } },
select: { id: true },
});
return [...siteIds, ...zones.map((z) => z.id)];
}
/** Options minimales pour le signalement — ouvert à tout rôle authentifié,
* filtré au périmètre du Demandeur s'il est affecté à un site (R6.6). */
async options(user: AuthenticatedUser): Promise<AssetOptionsResponse> {
const allowed = await this.allowedLocationIds(user);
const rows = await this.prisma.asset.findMany({
where: allowed ? { locationId: { in: allowed } } : undefined,
include: { location: { include: { parent: true } } },
orderBy: { reference: 'asc' },
});

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { AssetsModule } from '../assets/assets.module';
import { WorkOrdersModule } from '../work-orders/work-orders.module';
import { RequestsController } from './requests.controller';
import { RequestsService } from './requests.service';
@Module({
imports: [WorkOrdersModule],
imports: [WorkOrdersModule, AssetsModule],
controllers: [RequestsController],
providers: [RequestsService],
})

View File

@@ -16,6 +16,7 @@ import type {
import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { PermissionsService } from '../permissions/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import { AssetsService } from '../assets/assets.service';
import { WorkOrdersService } from '../work-orders/work-orders.service';
const requestInclude = {
@@ -32,6 +33,7 @@ export class RequestsService {
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly workOrders: WorkOrdersService,
private readonly assets: AssetsService,
) {}
private async scope(user: AuthenticatedUser): Promise<Prisma.RequestWhereInput> {
@@ -52,6 +54,13 @@ export class RequestsService {
async create(dto: RequestCreate, user: AuthenticatedUser): Promise<RequestSummary> {
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
if (!asset) throw new BadRequestException('Équipement inconnu');
// Défense en profondeur (R6.6) : même filtre que /assets/options, pour
// qu'un Demandeur affecté à un site ne puisse pas contourner la liste
// en soumettant directement un assetId hors périmètre.
const allowed = await this.assets.allowedLocationIds(user);
if (allowed && !allowed.includes(asset.locationId)) {
throw new BadRequestException("Cet équipement n'est pas dans votre périmètre");
}
for (let essai = 0; ; essai++) {
try {
const created = await this.prisma.request.create({

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
@@ -20,6 +21,7 @@ import { PrismaService } from '../prisma/prisma.service';
const userInclude = {
role: true,
teams: { orderBy: { name: 'asc' } },
assignedSites: { orderBy: { name: 'asc' } },
} satisfies Prisma.UserInclude;
type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>;
@@ -46,6 +48,7 @@ export class UsersService {
async invite(dto: InvitationCreate): Promise<InvitationResponse> {
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
if (!role) throw new NotFoundException('Rôle inconnu');
if (dto.locationIds?.length) await this.assertTopLevelSites(dto.locationIds);
try {
const user = await this.prisma.user.create({
data: {
@@ -56,6 +59,9 @@ export class UsersService {
teams: dto.teamIds?.length
? { connect: dto.teamIds.map((id) => ({ id })) }
: undefined,
assignedSites: dto.locationIds?.length
? { connect: dto.locationIds.map((id) => ({ id })) }
: undefined,
...this.freshToken(),
},
});
@@ -90,6 +96,7 @@ export class UsersService {
}
async update(userId: string, dto: UserUpdate): Promise<UserAdmin> {
if (dto.locationIds) await this.assertTopLevelSites(dto.locationIds);
try {
const updated = await this.prisma.user.update({
where: { id: userId },
@@ -102,6 +109,9 @@ export class UsersService {
teams: dto.teamIds
? { set: dto.teamIds.map((id) => ({ id })) }
: undefined,
assignedSites: dto.locationIds
? { set: dto.locationIds.map((id) => ({ id })) }
: undefined,
},
include: userInclude,
});
@@ -114,6 +124,16 @@ export class UsersService {
}
}
/** R6.6 : un Demandeur n'est affecté qu'à des sites racine, jamais des zones
* — même invariant que la hiérarchie site/zone (LocationsService.assertDepth). */
private async assertTopLevelSites(ids: string[]): Promise<void> {
const sites = await this.prisma.location.findMany({ where: { id: { in: ids } } });
if (sites.length !== ids.length) throw new BadRequestException('Site inconnu');
if (sites.some((s) => s.parentId)) {
throw new BadRequestException("L'affectation d'un Demandeur se fait à un site, pas à une zone");
}
}
private freshToken() {
return {
activationToken: randomBytes(32).toString('base64url'),
@@ -138,6 +158,7 @@ export class UsersService {
: 'invited',
isDemo: row.isDemo,
hourlyRate: row.hourlyRate === null ? null : Number(row.hourlyRate),
assignedSites: row.assignedSites.map((s) => ({ id: s.id, name: s.name })),
};
}
}

View File

@@ -55,9 +55,10 @@ describe('Exploitation (e2e)', () => {
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
it('recette : demande → approbation → OT assigné → bilan → clôture → suivi', async () => {
// 1. Karim (gardien) signale
// 1. Karim (gardien) signale — sur A2, dans son site rattaché (Tour
// Atlas, R6.6) : un Demandeur ne peut plus signaler hors périmètre.
const { body: assets } = await http().get('/assets').set(auth(salma));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A2');
const demande = await http()
.post('/requests')
.set(auth(karim))
@@ -234,8 +235,9 @@ describe('Exploitation (e2e)', () => {
});
it('rejet : motif obligatoire ; bilan : valeur hors champ refusée', async () => {
// B1 : dans le site rattaché de Karim (Tour Atlas, R6.6).
const { body: assets } = await http().get('/assets').set(auth(salma));
const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'C1');
const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'B1');
const demande = await http()
.post('/requests')
.set(auth(karim))

View File

@@ -1416,6 +1416,11 @@ export interface components {
status: "active" | "invited" | "disabled";
isDemo: boolean;
hourlyRate: number | null;
assignedSites: {
/** Format: uuid */
id: string;
name: string;
}[];
}[];
};
RolesResponse: {
@@ -1441,6 +1446,7 @@ export interface components {
roleId: string;
teamIds?: string[];
phone?: string;
locationIds?: string[];
};
UserAdmin: {
/** Format: uuid */
@@ -1464,6 +1470,11 @@ export interface components {
status: "active" | "invited" | "disabled";
isDemo: boolean;
hourlyRate: number | null;
assignedSites: {
/** Format: uuid */
id: string;
name: string;
}[];
};
UserUpdate: {
displayName?: string;
@@ -1473,6 +1484,7 @@ export interface components {
teamIds?: string[];
isActive?: boolean;
hourlyRate?: number | null;
locationIds?: string[];
};
DocumentsResponse: {
documents: {

View File

@@ -1,13 +1,19 @@
import { useState } from 'react';
import { Pressable, Text, TextInput, View } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useRef, useState } from 'react';
import { Platform, Pressable, Text, TextInput, View } from 'react-native';
import { useAssetOptions, useCreateRequest } from '@/api/exploitation';
import { analyseScan } from '@/lib/scan';
import { useTokens } from '@/theme/tokens';
import { BoutonTel, ChoixTel } from './ui';
/** Formulaire « Nouvelle demande » — mêmes champs que la modale de
* signalement du web (asset via `/assets/options`, accessible même sans
* ASSETS.view ; description ; personne bloquée). Utilisé à la fois par
* l'onglet du Demandeur et par le Menu des rôles gestion. */
* l'onglet du Demandeur et par le Menu des rôles gestion.
* Scan QR (R6.6) : raccourci mobile pour resélectionner un équipement déjà
* dans son périmètre — résolution UNIQUEMENT contre les options déjà
* chargées (déjà filtrées par site pour un Demandeur affecté), jamais un
* repli sur le parc complet qui annulerait la restriction. */
export function FormulaireDemande({ surSucces }: { surSucces: (id: string) => void }) {
const t = useTokens();
const { data: options } = useAssetOptions();
@@ -15,9 +21,31 @@ export function FormulaireDemande({ surSucces }: { surSucces: (id: string) => vo
const [assetId, setAssetId] = useState<string | null>(null);
const [description, setDescription] = useState('');
const [personneBloquee, setPersonneBloquee] = useState(false);
const [scanOuvert, setScanOuvert] = useState(false);
const [erreurScan, setErreurScan] = useState<string | null>(null);
const [permission, demanderPermission] = useCameraPermissions();
const dernierScan = useRef(0);
const asset = (options ?? []).find((a) => a.id === assetId) ?? null;
const valide = !!assetId && description.trim().length >= 3;
const cameraUtilisable = Platform.OS !== 'web' && permission?.granted;
const surScan = ({ data }: { data: string }) => {
const maintenant = Date.now();
if (maintenant - dernierScan.current < 1500) return; // anti-rafale
dernierScan.current = maintenant;
const reference = analyseScan(data);
const trouve = reference
? (options ?? []).find((a) => a.reference.toUpperCase() === reference)
: null;
if (!trouve) {
setErreurScan("Cet appareil n'existe pas ou n'est pas dans votre périmètre.");
return;
}
setErreurScan(null);
setAssetId(trouve.id);
setScanOuvert(false);
};
const envoyer = () => {
if (!assetId) return;
@@ -36,6 +64,62 @@ export function FormulaireDemande({ surSucces }: { surSucces: (id: string) => vo
options={(options ?? []).map((a) => ({ id: a.id, label: `${a.reference}${a.siteName}` }))}
surChoix={setAssetId}
/>
{scanOuvert ? (
<View style={{ gap: 8 }}>
{cameraUtilisable ? (
<View style={{ height: 220, borderRadius: 12, overflow: 'hidden' }}>
<CameraView
style={{ flex: 1 }}
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={surScan}
/>
</View>
) : (
<View
style={{
height: 220,
borderRadius: 12,
backgroundColor: '#131c2c',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
padding: 16,
}}
>
<Text
style={{
color: '#dfe7f2',
fontFamily: 'Manrope_600SemiBold',
fontSize: 12.5,
textAlign: 'center',
}}
>
{Platform.OS === 'web'
? 'Caméra indisponible sur web.'
: "Visez le QR de l'étiquette de cabine."}
</Text>
{Platform.OS !== 'web' && !permission?.granted ? (
<BoutonTel libelle="Autoriser la caméra" surAppui={() => void demanderPermission()} />
) : null}
</View>
)}
<BoutonTel libelle="Annuler le scan" variante="gris" surAppui={() => setScanOuvert(false)} />
</View>
) : (
<BoutonTel
libelle="📷 Scanner l'étiquette"
variante="contour"
surAppui={() => {
setErreurScan(null);
setScanOuvert(true);
}}
/>
)}
{erreurScan ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
{erreurScan}
</Text>
) : null}
<View style={{ gap: 4 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
Description <Text style={{ color: t.danger }}>*</Text>

View File

@@ -1416,6 +1416,11 @@ export interface components {
status: "active" | "invited" | "disabled";
isDemo: boolean;
hourlyRate: number | null;
assignedSites: {
/** Format: uuid */
id: string;
name: string;
}[];
}[];
};
RolesResponse: {
@@ -1441,6 +1446,7 @@ export interface components {
roleId: string;
teamIds?: string[];
phone?: string;
locationIds?: string[];
};
UserAdmin: {
/** Format: uuid */
@@ -1464,6 +1470,11 @@ export interface components {
status: "active" | "invited" | "disabled";
isDemo: boolean;
hourlyRate: number | null;
assignedSites: {
/** Format: uuid */
id: string;
name: string;
}[];
};
UserUpdate: {
displayName?: string;
@@ -1473,6 +1484,7 @@ export interface components {
teamIds?: string[];
isActive?: boolean;
hourlyRate?: number | null;
locationIds?: string[];
};
DocumentsResponse: {
documents: {

View File

@@ -1,8 +1,9 @@
import { useState, type FormEvent } from 'react';
import type { InvitationResponse } from '@siop/shared';
import type { InvitationResponse, UserAdmin } from '@siop/shared';
import {
useCreateTeam,
useInviteUser,
useLocations,
useResendInvitation,
useRoles,
useTeams,
@@ -46,6 +47,7 @@ export default function PagePersonnes() {
<th>Rôle</th>
<th>Équipe</th>
<th>Taux horaire</th>
<th>Sites</th>
<th>Statut</th>
<th></th>
</tr>
@@ -75,6 +77,13 @@ export default function PagePersonnes() {
editable={can('PEOPLE_TEAMS', 'edit')}
/>
</td>
<td>
{u.role.name === 'Demandeur' ? (
<CelluleSites utilisateur={u} editable={can('PEOPLE_TEAMS', 'edit')} />
) : (
'—'
)}
</td>
<td>
{u.status === 'active' ? (
<span className="st termine">Actif</span>
@@ -206,6 +215,90 @@ function CelluleTaux({
);
}
/** Sites autorisés en signalement (R6.6) — un Demandeur ne choisit son
* équipement que parmi ceux de ses sites rattachés (web + mobile, même
* filtre côté API : `/assets/options`). */
function CelluleSites({
utilisateur,
editable,
}: {
utilisateur: UserAdmin;
editable: boolean;
}) {
const [modale, setModale] = useState(false);
return (
<>
<span className="num">
{utilisateur.assignedSites.map((s) => s.name).join(', ') || '—'}
{editable ? (
<Button
variant="ghost"
className="ml-1 px-2 py-0.5 text-[12px]"
aria-label="Modifier les sites rattachés"
onClick={() => setModale(true)}
>
</Button>
) : null}
</span>
<ModaleSites utilisateur={utilisateur} ouverte={modale} surFermeture={setModale} />
</>
);
}
function ModaleSites({
utilisateur,
ouverte,
surFermeture,
}: {
utilisateur: UserAdmin;
ouverte: boolean;
surFermeture: (o: boolean) => void;
}) {
const { data: locations } = useLocations();
const sites = (locations ?? []).filter((l) => l.parentId === null);
const maj = useUpdateUser();
const [coches, setCoches] = useState<string[]>(() => utilisateur.assignedSites.map((s) => s.id));
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
maj.mutate(
{ id: utilisateur.id, locationIds: coches },
{ onSuccess: () => surFermeture(false) },
);
};
return (
<Modale titre={`Sites de ${utilisateur.displayName}`} ouverte={ouverte} surFermeture={surFermeture}>
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
{sites.map((s) => (
<label key={s.id} className="flex items-center gap-2 text-[13px]">
<input
type="checkbox"
checked={coches.includes(s.id)}
onChange={(e) =>
setCoches((c) => (e.target.checked ? [...c, s.id] : c.filter((id) => id !== s.id)))
}
/>
{s.name}
</label>
))}
</div>
<p className="note-douce">
Le signalement se limite aux sites cochés ci-dessus. Aucun site coché = aucune
restriction (accès au parc complet, comportement historique).
</p>
{maj.isError ? <p className="erreur-form" role="alert">{maj.error.message}</p> : null}
<div className="pied">
<Button onClick={() => surFermeture(false)}>Annuler</Button>
<Button type="submit" variant="prim" disabled={maj.isPending}>Enregistrer</Button>
</div>
</form>
</Modale>
);
}
function ModaleInvitation({
ouverte,
surFermeture,
@@ -217,7 +310,12 @@ function ModaleInvitation({
}) {
const { data: roles } = useRoles();
const { data: teams } = useTeams();
const { data: locations } = useLocations();
const invitation = useInviteUser();
const [roleId, setRoleId] = useState('');
const [sitesCoches, setSitesCoches] = useState<string[]>([]);
const roleName = roles?.find((r) => r.id === roleId)?.name;
const sites = (locations ?? []).filter((l) => l.parentId === null);
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -229,10 +327,13 @@ function ModaleInvitation({
displayName: String(donnees.get('displayName')),
roleId: String(donnees.get('roleId')),
teamIds: teamId ? [teamId] : undefined,
locationIds: sitesCoches.length ? sitesCoches : undefined,
},
{
onSuccess: (reponse) => {
surFermeture(false);
setRoleId('');
setSitesCoches([]);
surLien(reponse);
},
},
@@ -253,7 +354,13 @@ function ModaleInvitation({
<div className="form-grille">
<div className="champ">
<label htmlFor="inv-role">Rôle *</label>
<select id="inv-role" name="roleId" required defaultValue="">
<select
id="inv-role"
name="roleId"
required
value={roleId}
onChange={(e) => setRoleId(e.target.value)}
>
<option value="" disabled>Choisir</option>
{(roles ?? []).map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
@@ -270,6 +377,30 @@ function ModaleInvitation({
</select>
</div>
</div>
{roleName === 'Demandeur' ? (
<div className="champ">
<label>Sites autorisés en signalement (optionnel)</label>
<div className="flex flex-col gap-1">
{sites.map((s) => (
<label key={s.id} className="flex items-center gap-2 text-[13px]">
<input
type="checkbox"
checked={sitesCoches.includes(s.id)}
onChange={(e) =>
setSitesCoches((c) =>
e.target.checked ? [...c, s.id] : c.filter((id) => id !== s.id),
)
}
/>
{s.name}
</label>
))}
</div>
<p className="note-douce">
Aucun site coché = aucune restriction (accès au parc complet).
</p>
</div>
) : null}
<p className="note-douce">
La personne recevra un <b>lien d'activation valable 7 jours</b> pour choisir
son mot de passe. Aucun compte n'est actif avant cela.