@@ -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+ } ) ;
0 commit comments