Skip to content

Commit 7550188

Browse files
authored
Merge pull request #176 from mathiscapart/fix/email-compte-reserve-admin
fix(roles): une adresse email déjà attribuée n'est modifiable que par l'ADMIN
2 parents fc09baa + 3214d0f commit 7550188

7 files changed

Lines changed: 240 additions & 16 deletions

File tree

‎src/app/(app)/admin/utilisateurs/[id]/modifier/UserAccountForm.tsx‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ interface UserAccountFormProps {
2323
canLogin: boolean;
2424
birthDate: string | null;
2525
};
26+
// Remplacer une adresse réelle donne l'accès au compte (« mot de passe
27+
// oublié ») : canChangeAccountEmail. Sinon le champ est affiché en lecture
28+
// seule.
29+
canEditEmail: boolean;
2630
}
2731

2832
// US-CM-01 (évolution) — édition complète d'un compte par l'admin/secrétaire.
@@ -32,7 +36,7 @@ interface UserAccountFormProps {
3236
// Reste sur la page après l'enregistrement (point central de gestion du
3337
// compte, cf. section "Gestion du compte" plus bas) plutôt que de renvoyer
3438
// vers la liste.
35-
export function UserAccountForm({ user }: UserAccountFormProps) {
39+
export function UserAccountForm({ user, canEditEmail }: UserAccountFormProps) {
3640
const router = useRouter();
3741
const [state, formAction, pending] = useActionState(
3842
updateUserAccount,
@@ -81,7 +85,7 @@ export function UserAccountForm({ user }: UserAccountFormProps) {
8185
Ce compte n'a pas de connexion propre (compte enfant, cf.
8286
US-CM-01).{" "}
8387
{canEnableLogin(user.birthDate)
84-
? "Renseigner une vraie adresse email ici rendra ce compte connectable."
88+
? "Renseigner une vraie adresse email ici rendra ce compte connectable. Les parents rattachés en seront prévenus."
8589
: "Ce jeune a moins de 15 ans : son compte reste géré par un parent, il ne peut pas devenir connectable."}
8690
</p>
8791
) : null}
@@ -94,7 +98,15 @@ export function UserAccountForm({ user }: UserAccountFormProps) {
9498
type="email"
9599
defaultValue={user.email}
96100
required
101+
readOnly={!canEditEmail}
102+
aria-describedby={canEditEmail ? undefined : "email-hint"}
97103
/>
104+
{canEditEmail ? null : (
105+
<p id="email-hint" className="text-xs text-trail">
106+
Seul l&apos;administrateur peut modifier une adresse email déjà
107+
attribuée.
108+
</p>
109+
)}
98110
</div>
99111

100112
<div className="space-y-1.5">

‎src/app/(app)/admin/utilisateurs/[id]/modifier/page.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Link from "next/link";
33
import { notFound } from "next/navigation";
44

55
import { db } from "@/lib/db";
6-
import { can, canAssignRole } from "@/lib/permissions";
6+
import { can, canAssignRole, canChangeAccountEmail } from "@/lib/permissions";
77
import { requireCan } from "@/lib/require-can";
88
import {
99
BirthDateEditor,
@@ -108,6 +108,7 @@ export default async function EditUserAccountPage({ params }: PageProps) {
108108
canLogin: target.canLogin,
109109
birthDate: toDateInput(target.birthDate),
110110
}}
111+
canEditEmail={canChangeAccountEmail(currentUser, target)}
111112
/>
112113

113114
<section className="space-y-4 rounded-2xl bg-snow p-5 shadow-card">

‎src/lib/audit-redaction.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,25 @@ describe("redactAuditMetadata", () => {
8484
}
8585
});
8686

87+
it("retire les adresses email d'un changement d'email (USER_ACCOUNT_UPDATED)", () => {
88+
const out = redactAuditMetadata(
89+
JSON.stringify({
90+
targetUserId: USER_ID,
91+
fields: ["firstName", "lastName", "email", "phone"],
92+
canLoginEnabled: true,
93+
previousEmail: "ancienne@example.invalid",
94+
newEmail: "nouvelle@example.invalid",
95+
}),
96+
USER_ID,
97+
);
98+
expect(JSON.parse(out!)).toEqual({
99+
targetUserId: USER_ID,
100+
fields: ["firstName", "lastName", "email", "phone"],
101+
canLoginEnabled: true,
102+
redacted: true,
103+
});
104+
});
105+
87106
it("conserve la trace de la purge automatique", () => {
88107
const raw = JSON.stringify({
89108
targetUserId: USER_ID,

‎src/lib/enums.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ export const NOTIFICATION_TYPES = [
309309
"REPORT_CREATED", // SAFE-02 — signalement créé (→ les modérateurs concernés : ADMIN + CHEF de l'unité)
310310
"REPORT_UPDATE", // SAFE-02 — signalement traité (résolu ou rejeté) → le signalant
311311
"ACCOUNT_UPDATE", // inscription validée ou refusée → le demandeur
312-
"SECURITY_ALERT", // #147 connexion / #153 changement de mot de passe bloqués après plusieurs mots de passe erronés → le titulaire
312+
"SECURITY_ALERT", // #147 connexion / #153 changement de mot de passe bloqués après plusieurs mots de passe erronés → le titulaire ; adresse de connexion ajoutée à un compte enfant → ses parents
313313
] as const;
314314
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
315315

‎src/lib/permissions.test.ts‎

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
canAccessAdminZone,
1414
canActOnUnit,
1515
canAssignRole,
16+
canChangeAccountEmail,
17+
canSetEmailTo,
1618
canManagePlace,
1719
canReadPedagoNotes,
1820
canReviewExpense,
@@ -569,6 +571,7 @@ describe("RESPONSABLE_GROUPE — écriture sur tout le groupe (#173)", () => {
569571
"pedago.manage",
570572
"pedago.referential",
571573
"user.password.set",
574+
"user.email.set",
572575
"user.delete",
573576
"message.edit_any",
574577
];
@@ -727,8 +730,14 @@ describe("can — channel.moderate", () => {
727730

728731
// #173 — définir le mot de passe d'un compte et supprimer un compte : ADMIN seul
729732
// (décision du 2026-09-24). La SECRÉTAIRE et le RG gardent le reste de user.manage.
730-
describe("can — user.password.set / user.delete (ADMIN seul)", () => {
731-
it.each([["user.password.set"], ["user.delete"]] as const)("%s : ADMIN seul", (action) => {
733+
// Changer l'email d'un compte revient à pouvoir s'y connecter (« mot de passe
734+
// oublié » vers la nouvelle adresse) : même règle que user.password.set.
735+
describe("can — user.password.set / user.email.set / user.delete (ADMIN seul)", () => {
736+
it("user.email.set est une action déclarée de la matrice", () => {
737+
expect(ACTIONS).toContain("user.email.set");
738+
});
739+
740+
it.each([["user.password.set"], ["user.email.set"], ["user.delete"]] as const)("%s : ADMIN seul", (action) => {
732741
expect(can({ role: "ADMIN", roles: ["ADMIN"], status: "ACTIVE" }, action)).toBe(true);
733742
for (const role of ["RESPONSABLE_GROUPE", "SECRETAIRE", "CHEF", "TRESORIER"]) {
734743
expect(can({ role, roles: [role], status: "ACTIVE" }, action)).toBe(false);
@@ -755,3 +764,69 @@ describe("can — message.edit_any (ADMIN seul)", () => {
755764
}
756765
});
757766
});
767+
768+
// Changer l'email d'un compte qui se connecte, c'est pouvoir s'y connecter à sa
769+
// place (« mot de passe oublié ») : ADMIN seul. Un compte sans connexion
770+
// (compte enfant) peut recevoir sa première adresse de qui gère les comptes.
771+
describe("canChangeAccountEmail", () => {
772+
const u = (role: string) => ({ id: `${role}-1`, role, roles: [role], status: "ACTIVE" });
773+
const connectable = { canLogin: true, email: "adulte@example.fr", status: "ACTIVE" };
774+
const childAccount = { canLogin: false, email: "enfant-abc@piloti.invalid", status: "ACTIVE" };
775+
776+
it("ADMIN : tout compte", () => {
777+
expect(canChangeAccountEmail(u("ADMIN"), connectable)).toBe(true);
778+
expect(canChangeAccountEmail(u("ADMIN"), childAccount)).toBe(true);
779+
});
780+
781+
it.each(["RESPONSABLE_GROUPE", "SECRETAIRE"])("%s : compte sans connexion seulement", (role) => {
782+
expect(canChangeAccountEmail(u(role), childAccount)).toBe(true);
783+
expect(canChangeAccountEmail(u(role), connectable)).toBe(false);
784+
});
785+
786+
it.each(["CHEF", "TRESORIER", "PARENT"])("%s : jamais", (role) => {
787+
expect(canChangeAccountEmail(u(role), childAccount)).toBe(false);
788+
expect(canChangeAccountEmail(u(role), connectable)).toBe(false);
789+
});
790+
791+
// Revue : retirer les rôles d'un adulte puis lui donner une date de moins de
792+
// 15 ans le passe en canLogin=false sans toucher à son adresse réelle. Seule
793+
// l'adresse provisoire d'un compte enfant peut être remplacée.
794+
it.each(["RESPONSABLE_GROUPE", "SECRETAIRE"])(
795+
"%s : refusé sur un compte sans connexion qui a déjà une adresse réelle",
796+
(role) => {
797+
expect(
798+
canChangeAccountEmail(u(role), { canLogin: false, email: "adulte@example.fr", status: "ACTIVE" }),
799+
).toBe(false);
800+
},
801+
);
802+
803+
// Revue : un compte anonymisé garde canLogin=false et une adresse provisoire.
804+
// Lui en donner une réelle réintroduirait de la PII dans une fiche effacée.
805+
it.each(["RESPONSABLE_GROUPE", "SECRETAIRE"])("%s : refusé sur un compte supprimé", (role) => {
806+
const deleted = { canLogin: false, email: "deleted+abc@piloti.invalid", status: "DELETED" };
807+
expect(canChangeAccountEmail(u(role), deleted)).toBe(false);
808+
expect(canChangeAccountEmail(u("ADMIN"), deleted)).toBe(true);
809+
});
810+
811+
it("un compte suspendu ne change rien", () => {
812+
const suspended = { id: "sec-1", role: "SECRETAIRE", roles: ["SECRETAIRE"], status: "SUSPENDED" };
813+
expect(canChangeAccountEmail(suspended, childAccount)).toBe(false);
814+
});
815+
});
816+
817+
// Revue : poser à la main une adresse provisoire (ex. `deleted+<id>@piloti.invalid`
818+
// d'un autre compte) bloquerait l'anonymisation de ce compte (unicité). Aucun
819+
// usage légitime hors ADMIN.
820+
describe("canSetEmailTo", () => {
821+
const u = (role: string) => ({ id: `${role}-1`, role, roles: [role], status: "ACTIVE" });
822+
823+
it.each(["RESPONSABLE_GROUPE", "SECRETAIRE"])("%s : adresse réelle oui, provisoire non", (role) => {
824+
expect(canSetEmailTo(u(role), "jeune@example.fr")).toBe(true);
825+
expect(canSetEmailTo(u(role), "deleted+abc@piloti.invalid")).toBe(false);
826+
expect(canSetEmailTo(u(role), "enfant-xyz@piloti.invalid")).toBe(false);
827+
});
828+
829+
it("ADMIN : toute adresse", () => {
830+
expect(canSetEmailTo(u("ADMIN"), "enfant-xyz@piloti.invalid")).toBe(true);
831+
});
832+
});

‎src/lib/permissions.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export const ACTIONS = [
5353
"user.approve", // valider/refuser les inscriptions (+ attribuer les rôles)
5454
"user.manage", // gérer les comptes existants : rôles (page /admin/utilisateurs)
5555
"user.password.set", // définir le mot de passe d'un compte (ADMIN seul, #173)
56+
"user.email.set", // modifier une adresse email déjà attribuée (ADMIN seul)
5657
"user.delete", // supprimer (anonymiser) un compte (ADMIN seul, #173)
5758
"member.view",
5859
"member.family.manage", // rattachement parent ↔ jeune (CHEF, RG, SEC)
@@ -167,6 +168,11 @@ const PERMISSIONS: Record<Action, Role[]> = {
167168
// #173 — se connecter à la place de quelqu'un ou effacer un compte sont
168169
// réservés à l'ADMIN, secrétaire et RG compris (décision du 2026-09-24).
169170
"user.password.set": [],
171+
// Changer l'email d'un compte qui se connecte donne l'accès au compte :
172+
// « mot de passe oublié » envoie le lien à la nouvelle adresse. Même règle
173+
// que user.password.set, sinon celle-ci se contourne. Les comptes sans
174+
// connexion suivent canChangeAccountEmail.
175+
"user.email.set": [],
170176
"user.delete": [],
171177
"member.view": [CHEF, RG, SEC, TRES],
172178
// Rattachement familial parent ↔ jeune. Permission DÉDIÉE, volontairement
@@ -370,6 +376,43 @@ export function canReadPedagoNotes(user: AuthCtx, jeuneUnit: string | null): boo
370376
return canActOnUnit(user, "pedago.manage", jeuneUnit);
371377
}
372378

379+
// Adresse provisoire d'un compte enfant, qui n'a pas d'email réel
380+
// (createChildAccount). Domaine réservé (RFC 2606) : jamais délivrable.
381+
export const PLACEHOLDER_EMAIL_SUFFIX = "@piloti.invalid";
382+
383+
/**
384+
* Modifier l'email d'un compte. Sur un compte qui a une adresse réelle, c'est
385+
* pouvoir s'y connecter à sa place (« mot de passe oublié ») : ADMIN seul
386+
* (`user.email.set`). Un compte enfant sans connexion peut recevoir sa
387+
* PREMIÈRE adresse de qui gère les comptes (`user.manage`) ; l'appelant
388+
* prévient alors les parents rattachés. Exiger l'adresse provisoire, et pas
389+
* seulement canLogin=false : retirer ses rôles à un adulte puis lui donner une
390+
* date de moins de 15 ans le passe en canLogin=false sans changer son adresse.
391+
* Un compte supprimé (anonymisé) est exclu : il a lui aussi une adresse
392+
* provisoire, et y remettre une adresse réelle réintroduirait de la PII.
393+
*/
394+
export function canChangeAccountEmail(
395+
user: AuthCtx,
396+
target: { canLogin: boolean; email: string; status: string },
397+
): boolean {
398+
if (can(user, "user.email.set")) return true;
399+
return (
400+
target.canLogin === false &&
401+
target.status !== "DELETED" &&
402+
target.email.endsWith(PLACEHOLDER_EMAIL_SUFFIX) &&
403+
can(user, "user.manage")
404+
);
405+
}
406+
407+
/**
408+
* Hors ADMIN, une nouvelle adresse ne peut pas être provisoire : poser à la
409+
* main `deleted+<id>@piloti.invalid` d'un autre compte bloquerait son
410+
* anonymisation (contrainte d'unicité). Seul le code génère ces adresses.
411+
*/
412+
export function canSetEmailTo(user: AuthCtx, newEmail: string): boolean {
413+
return can(user, "user.email.set") || !newEmail.endsWith(PLACEHOLDER_EMAIL_SUFFIX);
414+
}
415+
373416
/**
374417
* #103 / #173 — valider, refuser ou rembourser une note de frais. Le RG ne
375418
* traite pas SA propre note, même s'il est aussi trésorier. Le trésorier seul

0 commit comments

Comments
 (0)