From 790761d5768378af2b07c64513d23e4a05f23a10 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 9 Feb 2026 23:52:25 +0100 Subject: [PATCH 01/11] fix(modale): champs MDP actuel lavande, nouveau et confirmation jaune Co-authored-by: Cursor --- docs/23_LISTE-TICKETS.md | 46 +++++++++++++++++-- frontend/lib/screens/auth/login_screen.dart | 24 ++++++++++ .../widgets/auth/change_password_dialog.dart | 2 +- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/docs/23_LISTE-TICKETS.md b/docs/23_LISTE-TICKETS.md index 15b1b7d..087a6ad 100644 --- a/docs/23_LISTE-TICKETS.md +++ b/docs/23_LISTE-TICKETS.md @@ -25,8 +25,10 @@ Correspondance entre les numéros d’issues Gitea et les tickets de ce document | 79 | Frontend - Renommer Nanny en AM | P3 | ✅ Fermé | § Ticket #79 | | 81 | Frontend - Corrections refactoring widgets | P3 | ✅ Fermé | § Ticket #81 | | 83 | Frontend - RegisterChoiceScreen mobile | P3 | ✅ Fermé | § Ticket #83 | +| 84 | Bug - Connexion admin : erreur profil et redirection | P3 | ✅ Fermé | § Ticket #84 | +| 85 | Frontend - Bug correctifs modale Changement MDP | P3 | Ouvert | § Ticket #85 | -*Les autres tickets (sans numéro Gitea dans ce tableau) sont décrits dans les sections par priorité ci‑dessous ; les numéros de section (#1 à #83) sont les références internes du document.* +*Les autres tickets (sans numéro Gitea dans ce tableau) sont décrits dans les sections par priorité ci‑dessous ; les numéros de section (#1 à #85) ; #84 et #85 ont un numéro Gitea. sont les références internes du document.* **Point API (tickets frontend)** – 27/01/2026 : 20 issues avec le label `frontend` dans Gitea (12 ouvertes, 8 fermées). Numéros concernés : 35–42, 43–51, 54, 82, 83. Les #73, #78, #79, #81 sont fermés mais sans label dans l’API. Détail : `docs/POINT_TICKETS_FRONT_API.txt`. @@ -855,6 +857,38 @@ Créer l'écran de changement de mot de passe obligatoire (première connexion g --- +### Ticket #84 : [Bug] Connexion admin – erreur récupération profil et pas de redirection +**Gitea** : [#84](https://git.ptits-pas.fr/jmartin/petitspas/issues/84) +**Statut** : ✅ Fermé + +**Description** : +Bug à la connexion admin : erreur lors de la récupération du profil et absence de redirection attendue. + +--- + +### Ticket #85 : [Frontend] Bug – Correctifs modale Changement MDP (première connexion admin) +**Gitea** : [#85](https://git.ptits-pas.fr/jmartin/petitspas/issues/85) +**Estimation** : 1h +**Labels** : `frontend`, `p3`, `bug`, `auth`, `ux` + +**Description** : +Correctifs et améliorations de la modale de changement de mot de passe obligatoire affichée à la première connexion admin (lien avec Ticket #47). + +**Périmètre** : +- Ajustements visuels / UX de la modale (`ChangePasswordDialog`) +- Cohérence charte graphique, espacements, lisibilité +- Comportement (validation, messages d'erreur, fermeture) +- Lien de test en debug sur l'écran login (« Test modale MDP ») pour faciliter les réglages + +**Tâches** : +- [ ] Revoir le design de la modale (relief, bordures, couleurs) +- [ ] Vérifier les champs (MDP actuel, nouveau, confirmation) et validations +- [ ] Ajuster les textes et messages d'erreur +- [ ] Tester sur mobile et desktop +- [ ] Retirer ou conditionner le lien « Test modale MDP » en production si besoin + +--- + ### Ticket #48 : [Frontend] Gestion Erreurs & Messages **Estimation** : 2h **Labels** : `frontend`, `p3`, `ux` @@ -1155,14 +1189,14 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit ## 📊 Résumé final -**Total** : 61 tickets -**Estimation** : ~173h de développement +**Total** : 63 tickets +**Estimation** : ~174h de développement ### Par priorité - **P0 (Bloquant BDD)** : 7 tickets (~5h) - **P1 (Bloquant Config)** : 7 tickets (~22h) - **P2 (Backend)** : 18 tickets (~50h) -- **P3 (Frontend)** : 18 tickets (~60h) ← +1 ticket logs admin, +1 ticket refonte formulaires +- **P3 (Frontend)** : 20 tickets (~62h) ← #84 bug connexion admin, #85 correctifs modale MDP - **P4 (Tests/Doc)** : 4 tickets (~24h) - **Critiques** : 6 tickets (~13h) ← -2 email, +1 logs, +1 CDC - **Juridique** : 1 ticket (~8h) @@ -1170,7 +1204,7 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit ### Par domaine - **BDD** : 7 tickets - **Backend** : 23 tickets ← +1 logs -- **Frontend** : 18 tickets ← +1 logs admin, +1 refonte formulaires +- **Frontend** : 20 tickets ← #84 bug connexion admin, #85 correctifs modale MDP - **Tests** : 3 tickets - **Documentation** : 5 tickets ← +1 amendement CDC - **Infra** : 2 tickets @@ -1181,6 +1215,8 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit - ✅ **Ajouté** : Ticket #55 "Service Logging Winston" - Monitoring essentiel - ✅ **Ajouté** : Ticket #56 "Écran Logs Admin" - Optionnel Phase 1.1 - ✅ **Ajouté** : Ticket #78 "Refonte Infrastructure Formulaires" - Harmonisation UI/UX +- ✅ **Ajouté** : Ticket #84 "Bug connexion admin – erreur profil et redirection" (Gitea #84, fermé) +- ✅ **Ajouté** : Ticket #85 "Bug – Correctifs modale Changement MDP" (Gitea #85) - Design, UX, validations, lien test debug --- diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart index 6d6cfd2..9dc2408 100644 --- a/frontend/lib/screens/auth/login_screen.dart +++ b/frontend/lib/screens/auth/login_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -360,6 +361,17 @@ class _LoginPageState extends State with WidgetsBindingObserver { context.go('/privacy'); }, ), + if (kDebugMode) + _FooterLink( + text: 'Test modale MDP', + onTap: () async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const ChangePasswordDialog(), + ); + }, + ), ], ), ), @@ -556,6 +568,18 @@ class _LoginPageState extends State with WidgetsBindingObserver { fontSize: 11, onTap: () => context.go('/privacy'), ), + if (kDebugMode) + _FooterLink( + text: 'Test modale MDP', + fontSize: 11, + onTap: () async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const ChangePasswordDialog(), + ); + }, + ), ], ), ), diff --git a/frontend/lib/widgets/auth/change_password_dialog.dart b/frontend/lib/widgets/auth/change_password_dialog.dart index f9f50a6..bf172c6 100644 --- a/frontend/lib/widgets/auth/change_password_dialog.dart +++ b/frontend/lib/widgets/auth/change_password_dialog.dart @@ -196,7 +196,7 @@ class _ChangePasswordDialogState extends State { hintText: 'Retapez le nouveau mot de passe', obscureText: true, validator: _validateConfirmPassword, - style: CustomAppTextFieldStyle.lavande, + style: CustomAppTextFieldStyle.jaune, fieldHeight: 53, fieldWidth: double.infinity, enabled: !_isLoading, From 67941909169d9988395276d45fc0d43697854234 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 9 Feb 2026 23:52:51 +0100 Subject: [PATCH 02/11] chore(login): retrait du lien Test modale MDP Co-authored-by: Cursor --- frontend/lib/screens/auth/login_screen.dart | 24 --------------------- 1 file changed, 24 deletions(-) diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart index 9dc2408..6d6cfd2 100644 --- a/frontend/lib/screens/auth/login_screen.dart +++ b/frontend/lib/screens/auth/login_screen.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -361,17 +360,6 @@ class _LoginPageState extends State with WidgetsBindingObserver { context.go('/privacy'); }, ), - if (kDebugMode) - _FooterLink( - text: 'Test modale MDP', - onTap: () async { - await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => const ChangePasswordDialog(), - ); - }, - ), ], ), ), @@ -568,18 +556,6 @@ class _LoginPageState extends State with WidgetsBindingObserver { fontSize: 11, onTap: () => context.go('/privacy'), ), - if (kDebugMode) - _FooterLink( - text: 'Test modale MDP', - fontSize: 11, - onTap: () async { - await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => const ChangePasswordDialog(), - ); - }, - ), ], ), ), From 0386785f81b357537d2b647fbb70a0268f47ad4c Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 13 Feb 2026 11:15:41 +0100 Subject: [PATCH 03/11] feat(backend): log des appels API en mode debug (#89) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajout LogRequestInterceptor (méthode, URL, query, body) - Activé via LOG_API_REQUESTS=true - Masquage des champs sensibles (password, smtp_password, token...) - Enregistrement global dans main.ts, doc dans .env.example Co-authored-by: Cursor --- backend/.env.example | 3 + .../interceptors/log-request.interceptor.ts | 69 +++++++++++++++++++ backend/src/main.ts | 11 +-- 3 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 backend/src/common/interceptors/log-request.interceptor.ts diff --git a/backend/.env.example b/backend/.env.example index 002d786..67081bd 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -21,3 +21,6 @@ JWT_EXPIRATION_TIME=7d # Environnement NODE_ENV=development + +# Log de chaque appel API (mode debug) — mettre à true pour tracer les requêtes front +# LOG_API_REQUESTS=true diff --git a/backend/src/common/interceptors/log-request.interceptor.ts b/backend/src/common/interceptors/log-request.interceptor.ts new file mode 100644 index 0000000..bfb87e0 --- /dev/null +++ b/backend/src/common/interceptors/log-request.interceptor.ts @@ -0,0 +1,69 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { Request } from 'express'; + +/** Clés à masquer dans les logs (corps de requête) */ +const SENSITIVE_KEYS = [ + 'password', + 'smtp_password', + 'token', + 'accessToken', + 'refreshToken', + 'secret', +]; + +function maskBody(body: unknown): unknown { + if (body === null || body === undefined) return body; + if (typeof body !== 'object') return body; + const out: Record = {}; + for (const [key, value] of Object.entries(body)) { + const lower = key.toLowerCase(); + const isSensitive = SENSITIVE_KEYS.some((s) => lower.includes(s)); + out[key] = isSensitive ? '***' : value; + } + return out; +} + +@Injectable() +export class LogRequestInterceptor implements NestInterceptor { + private readonly enabled: boolean; + + constructor() { + this.enabled = + process.env.LOG_API_REQUESTS === 'true' || + process.env.LOG_API_REQUESTS === '1'; + } + + intercept(context: ExecutionContext, next: CallHandler): Observable { + if (!this.enabled) return next.handle(); + + const http = context.switchToHttp(); + const req = http.getRequest(); + const { method, url, body, query } = req; + const hasBody = body && Object.keys(body).length > 0; + + const logLine = [ + `[API] ${method} ${url}`, + Object.keys(query || {}).length ? `query=${JSON.stringify(query)}` : '', + hasBody ? `body=${JSON.stringify(maskBody(body))}` : '', + ] + .filter(Boolean) + .join(' '); + + console.log(logLine); + + return next.handle().pipe( + tap({ + next: () => { + // Optionnel: log du statut en fin de requête (si besoin plus tard) + }, + }), + ); + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 3943463..6c62287 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,17 +1,18 @@ -import { NestFactory, Reflector } from '@nestjs/core'; +import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ConfigService } from '@nestjs/config'; import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module'; import { DocumentBuilder } from '@nestjs/swagger'; -import { AuthGuard } from './common/guards/auth.guard'; -import { JwtService } from '@nestjs/jwt'; -import { RolesGuard } from './common/guards/roles.guard'; import { ValidationPipe } from '@nestjs/common'; +import { LogRequestInterceptor } from './common/interceptors/log-request.interceptor'; async function bootstrap() { const app = await NestFactory.create(AppModule, { logger: ['error', 'warn', 'log', 'debug', 'verbose'] }); - + + // Log de chaque appel API si LOG_API_REQUESTS=true (mode debug) + app.useGlobalInterceptors(new LogRequestInterceptor()); + // Configuration CORS pour autoriser les requêtes depuis localhost (dev) et production app.enableCors({ origin: true, // Autorise toutes les origines (dev) - à restreindre en prod From 1834eb8c7946e696edec02b7091977533e16b755 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 13 Feb 2026 11:16:37 +0100 Subject: [PATCH 04/11] =?UTF-8?q?feat(admin):=20panneau=20Param=C3=A8tres?= =?UTF-8?q?=20-=20sauvegarde=20config=20+=20test=20SMTP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Onglet Paramètres dans l'admin avec 3 sections (Email, Personnalisation, Avancé) - Service ConfigurationService (GET config, PATCH bulk, POST test-smtp) - Bouton Sauvegarder et bouton Tester SMTP (sauvegarde avant test) - Endpoints api_config pour configuration Closes #15 Co-authored-by: Cursor --- .../admin_dashboardScreen.dart | 39 +- frontend/lib/services/api/api_config.dart | 7 + .../lib/services/configuration_service.dart | 139 ++++++ .../lib/widgets/admin/dashboard_admin.dart | 166 ++++--- .../lib/widgets/admin/parametres_panel.dart | 411 ++++++++++++++++++ 5 files changed, 688 insertions(+), 74 deletions(-) create mode 100644 frontend/lib/services/configuration_service.dart create mode 100644 frontend/lib/widgets/admin/parametres_panel.dart diff --git a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart index d10062e..be288bd 100644 --- a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart +++ b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart'; +import 'package:p_tits_pas/widgets/admin/parametres_panel.dart'; import 'package:p_tits_pas/widgets/app_footer.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; @@ -9,15 +10,25 @@ class AdminDashboardScreen extends StatefulWidget { const AdminDashboardScreen({super.key}); @override - _AdminDashboardScreenState createState() => _AdminDashboardScreenState(); + State createState() => _AdminDashboardScreenState(); } class _AdminDashboardScreenState extends State { - int selectedIndex = 0; + /// 0 = Gestion des utilisateurs, 1 = Paramètres + int mainTabIndex = 0; - void onTabChange(int index) { + /// Sous-onglet quand mainTabIndex == 0 : 0=Gestionnaires, 1=Parents, 2=AM, 3=Administrateurs + int subIndex = 0; + + void onMainTabChange(int index) { setState(() { - selectedIndex = index; + mainTabIndex = index; + }); + } + + void onSubTabChange(int index) { + setState(() { + subIndex = index; }); } @@ -33,13 +44,18 @@ class _AdminDashboardScreenState extends State { ), ), child: DashboardAppBarAdmin( - selectedIndex: selectedIndex, - onTabChange: onTabChange, + selectedIndex: mainTabIndex, + onTabChange: onMainTabChange, ), ), ), body: Column( children: [ + if (mainTabIndex == 0) + DashboardUserManagementSubBar( + selectedSubIndex: subIndex, + onSubTabChange: onSubTabChange, + ), Expanded( child: _getBody(), ), @@ -50,17 +66,20 @@ class _AdminDashboardScreenState extends State { } Widget _getBody() { - switch (selectedIndex) { + if (mainTabIndex == 1) { + return const ParametresPanel(); + } + switch (subIndex) { case 0: - return const GestionnaireManagementWidget(); + return const GestionnaireManagementWidget(); case 1: return const ParentManagementWidget(); case 2: return const AssistanteMaternelleManagementWidget(); case 3: - return const Center(child: Text("👨‍💼 Administrateurs")); + return const Center(child: Text('👨‍💼 Administrateurs')); default: - return const Center(child: Text("Page non trouvée")); + return const Center(child: Text('Page non trouvée')); } } } diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index bd157f8..831e911 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -14,6 +14,13 @@ class ApiConfig { static const String userProfile = '/users/profile'; static const String userChildren = '/users/children'; + // Configuration (admin) + static const String configuration = '/configuration'; + static const String configurationSetupStatus = '/configuration/setup/status'; + static const String configurationSetupComplete = '/configuration/setup/complete'; + static const String configurationTestSmtp = '/configuration/test-smtp'; + static const String configurationBulk = '/configuration/bulk'; + // Dashboard endpoints static const String dashboard = '/dashboard'; static const String events = '/events'; diff --git a/frontend/lib/services/configuration_service.dart b/frontend/lib/services/configuration_service.dart new file mode 100644 index 0000000..8f1c905 --- /dev/null +++ b/frontend/lib/services/configuration_service.dart @@ -0,0 +1,139 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'api/api_config.dart'; +import 'api/tokenService.dart'; + +/// Réponse GET /configuration (liste complète) +class ConfigItem { + final String cle; + final String? valeur; + final String type; + final String? categorie; + final String? description; + + ConfigItem({ + required this.cle, + this.valeur, + required this.type, + this.categorie, + this.description, + }); + + factory ConfigItem.fromJson(Map json) { + return ConfigItem( + cle: json['cle'] as String, + valeur: json['valeur'] as String?, + type: json['type'] as String? ?? 'string', + categorie: json['categorie'] as String?, + description: json['description'] as String?, + ); + } +} + +/// Réponse GET /configuration/:category (objet clé -> { value, type, description }) +class ConfigValueItem { + final dynamic value; + final String type; + final String? description; + + ConfigValueItem({required this.value, required this.type, this.description}); + + factory ConfigValueItem.fromJson(Map json) { + return ConfigValueItem( + value: json['value'], + type: json['type'] as String? ?? 'string', + description: json['description'] as String?, + ); + } +} + +class ConfigurationService { + static Future> _headers() async { + final token = await TokenService.getToken(); + return token != null + ? ApiConfig.authHeaders(token) + : Map.from(ApiConfig.headers); + } + + /// GET /api/v1/configuration/setup/status + static Future getSetupStatus() async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationSetupStatus}'), + headers: await _headers(), + ); + if (response.statusCode != 200) return true; + final data = jsonDecode(response.body); + return data['data']?['setupCompleted'] as bool? ?? true; + } + + /// GET /api/v1/configuration (toutes les configs) + static Future> getAll() async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configuration}'), + headers: await _headers(), + ); + if (response.statusCode != 200) { + throw Exception( + (jsonDecode(response.body) as Map)['message'] ?? 'Erreur chargement configuration', + ); + } + final data = jsonDecode(response.body); + final list = data['data'] as List? ?? []; + return list.map((e) => ConfigItem.fromJson(e as Map)).toList(); + } + + /// GET /api/v1/configuration/:category + static Future> getByCategory(String category) async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configuration}/$category'), + headers: await _headers(), + ); + if (response.statusCode != 200) { + throw Exception( + (jsonDecode(response.body) as Map)['message'] ?? 'Erreur chargement configuration', + ); + } + final data = jsonDecode(response.body); + final map = data['data'] as Map? ?? {}; + return map.map((k, v) => MapEntry(k, ConfigValueItem.fromJson(v as Map))); + } + + /// PATCH /api/v1/configuration/bulk + static Future updateBulk(Map body) async { + final response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationBulk}'), + headers: await _headers(), + body: jsonEncode(body), + ); + if (response.statusCode != 200) { + final err = jsonDecode(response.body) as Map; + throw Exception(err['message'] ?? 'Erreur lors de la sauvegarde'); + } + } + + /// POST /api/v1/configuration/test-smtp + static Future testSmtp(String testEmail) async { + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationTestSmtp}'), + headers: await _headers(), + body: jsonEncode({'testEmail': testEmail}), + ); + final data = jsonDecode(response.body) as Map; + if (response.statusCode == 200 && data['success'] == true) { + return data['message'] as String? ?? 'Test SMTP réussi.'; + } + throw Exception(data['error'] ?? data['message'] ?? 'Échec du test SMTP'); + } + + /// POST /api/v1/configuration/setup/complete (après première config) + static Future completeSetup() async { + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationSetupComplete}'), + headers: await _headers(), + ); + if (response.statusCode != 200) { + final err = jsonDecode(response.body) as Map; + throw Exception(err['message'] ?? 'Erreur finalisation configuration'); + } + } +} diff --git a/frontend/lib/widgets/admin/dashboard_admin.dart b/frontend/lib/widgets/admin/dashboard_admin.dart index 6f63760..d19030a 100644 --- a/frontend/lib/widgets/admin/dashboard_admin.dart +++ b/frontend/lib/widgets/admin/dashboard_admin.dart @@ -1,47 +1,47 @@ import 'package:flutter/material.dart'; +/// Barre principale du dashboard admin : 2 onglets (Gestion des utilisateurs | Paramètres) + infos utilisateur. class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidget { final int selectedIndex; final ValueChanged onTabChange; - const DashboardAppBarAdmin({Key? key, required this.selectedIndex, required this.onTabChange}) : super(key: key); + const DashboardAppBarAdmin({ + Key? key, + required this.selectedIndex, + required this.onTabChange, + }) : super(key: key); @override Size get preferredSize => const Size.fromHeight(kToolbarHeight + 10); @override Widget build(BuildContext context) { - final isMobile = MediaQuery.of(context).size.width < 768; return AppBar( elevation: 0, automaticallyImplyLeading: false, title: Row( children: [ - SizedBox(width: MediaQuery.of(context).size.width * 0.19), - const Text( - "P'tit Pas", - style: TextStyle( - color: Color(0xFF9CC5C0), - fontWeight: FontWeight.bold, - fontSize: 18, + const SizedBox(width: 24), + Image.asset( + 'assets/images/logo.png', + height: 40, + fit: BoxFit.contain, + ), + Expanded( + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildNavItem(context, 'Gestion des utilisateurs', 0), + const SizedBox(width: 24), + _buildNavItem(context, 'Paramètres', 1), + ], + ), ), ), - SizedBox(width: MediaQuery.of(context).size.width * 0.1), - - // Navigation principale - _buildNavItem(context, 'Gestionnaires', 0), - const SizedBox(width: 24), - _buildNavItem(context, 'Parents', 1), - const SizedBox(width: 24), - _buildNavItem(context, 'Assistantes maternelles', 2), - const SizedBox(width: 24), - _buildNavItem(context, 'Administrateurs', 3), ], ), - actions: isMobile - ? [_buildMobileMenu(context)] - : [ - // Nom de l'utilisateur + actions: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 16), child: Center( @@ -55,8 +55,6 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge ), ), ), - - // Bouton déconnexion Padding( padding: const EdgeInsets.only(right: 16), child: TextButton( @@ -72,51 +70,30 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge child: const Text('Se déconnecter'), ), ), - SizedBox(width: MediaQuery.of(context).size.width * 0.1), ], ); } Widget _buildNavItem(BuildContext context, String title, int index) { - final bool isActive = index == selectedIndex; - return InkWell( - onTap: () => onTabChange(index), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, - borderRadius: BorderRadius.circular(20), - border: isActive ? null : Border.all(color: Colors.black26), - ), - child: Text( - title, - style: TextStyle( - color: isActive ? Colors.white : Colors.black, - fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, - fontSize: 14, + final bool isActive = index == selectedIndex; + return InkWell( + onTap: () => onTabChange(index), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, + borderRadius: BorderRadius.circular(20), + border: isActive ? null : Border.all(color: Colors.black26), + ), + child: Text( + title, + style: TextStyle( + color: isActive ? Colors.white : Colors.black, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + fontSize: 14, + ), ), ), - ), - ); -} - - - Widget _buildMobileMenu(BuildContext context) { - return PopupMenuButton( - icon: const Icon(Icons.menu, color: Colors.white), - onSelected: (value) { - if (value == 4) { - _handleLogout(context); - } - }, - itemBuilder: (context) => [ - const PopupMenuItem(value: 0, child: Text("Gestionnaires")), - const PopupMenuItem(value: 1, child: Text("Parents")), - const PopupMenuItem(value: 2, child: Text("Assistantes maternelles")), - const PopupMenuItem(value: 3, child: Text("Administrateurs")), - const PopupMenuDivider(), - const PopupMenuItem(value: 4, child: Text("Se déconnecter")), - ], ); } @@ -142,4 +119,65 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge ), ); } -} \ No newline at end of file +} + +/// Sous-barre affichée quand "Gestion des utilisateurs" est actif : 4 onglets sans infos utilisateur. +class DashboardUserManagementSubBar extends StatelessWidget { + final int selectedSubIndex; + final ValueChanged onSubTabChange; + + const DashboardUserManagementSubBar({ + Key? key, + required this.selectedSubIndex, + required this.onSubTabChange, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + height: 48, + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border(bottom: BorderSide(color: Colors.grey.shade300)), + ), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildSubNavItem(context, 'Gestionnaires', 0), + const SizedBox(width: 16), + _buildSubNavItem(context, 'Parents', 1), + const SizedBox(width: 16), + _buildSubNavItem(context, 'Assistantes maternelles', 2), + const SizedBox(width: 16), + _buildSubNavItem(context, 'Administrateurs', 3), + ], + ), + ), + ); + } + + Widget _buildSubNavItem(BuildContext context, String title, int index) { + final bool isActive = index == selectedSubIndex; + return InkWell( + onTap: () => onSubTabChange(index), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, + borderRadius: BorderRadius.circular(16), + border: isActive ? null : Border.all(color: Colors.black26), + ), + child: Text( + title, + style: TextStyle( + color: isActive ? Colors.white : Colors.black87, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + fontSize: 13, + ), + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/parametres_panel.dart b/frontend/lib/widgets/admin/parametres_panel.dart new file mode 100644 index 0000000..c50ecdd --- /dev/null +++ b/frontend/lib/widgets/admin/parametres_panel.dart @@ -0,0 +1,411 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/services/configuration_service.dart'; + +/// Panneau Paramètres / Configuration (ticket #15) : 3 sections sur une page. +class ParametresPanel extends StatefulWidget { + const ParametresPanel({super.key}); + + @override + State createState() => _ParametresPanelState(); +} + +class _ParametresPanelState extends State { + final _formKey = GlobalKey(); + bool _isLoading = true; + String? _loadError; + bool _isSaving = false; + String? _message; + + final Map _controllers = {}; + bool _smtpSecure = false; + bool _smtpAuthRequired = false; + + @override + void initState() { + super.initState(); + _createControllers(); + _loadConfiguration(); + } + + void _createControllers() { + final keys = [ + 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', + 'email_from_name', 'email_from_address', + 'app_name', 'app_url', 'app_logo_url', + 'password_reset_token_expiry_days', 'jwt_expiry_hours', 'max_upload_size_mb', + ]; + for (final k in keys) { + _controllers[k] = TextEditingController(); + } + } + + Future _loadConfiguration() async { + setState(() { + _isLoading = true; + _loadError = null; + }); + try { + final list = await ConfigurationService.getAll(); + if (!mounted) return; + for (final item in list) { + final c = _controllers[item.cle]; + if (c != null && item.valeur != null && item.valeur != '***********') { + c.text = item.valeur!; + } + if (item.cle == 'smtp_secure') { + _smtpSecure = item.valeur == 'true'; + } + if (item.cle == 'smtp_auth_required') { + _smtpAuthRequired = item.valeur == 'true'; + } + } + setState(() { + _isLoading = false; + }); + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _loadError = e.toString().replaceAll('Exception: ', ''); + }); + } + } + } + + @override + void dispose() { + for (final c in _controllers.values) { + c.dispose(); + } + super.dispose(); + } + + Map _buildPayload() { + final payload = {}; + payload['smtp_host'] = _controllers['smtp_host']!.text.trim(); + final port = int.tryParse(_controllers['smtp_port']!.text.trim()); + if (port != null) payload['smtp_port'] = port; + payload['smtp_secure'] = _smtpSecure; + payload['smtp_auth_required'] = _smtpAuthRequired; + payload['smtp_user'] = _controllers['smtp_user']!.text.trim(); + final pwd = _controllers['smtp_password']!.text.trim(); + if (pwd.isNotEmpty && pwd != '***********') payload['smtp_password'] = pwd; + payload['email_from_name'] = _controllers['email_from_name']!.text.trim(); + payload['email_from_address'] = _controllers['email_from_address']!.text.trim(); + payload['app_name'] = _controllers['app_name']!.text.trim(); + payload['app_url'] = _controllers['app_url']!.text.trim(); + payload['app_logo_url'] = _controllers['app_logo_url']!.text.trim(); + final tokenDays = int.tryParse(_controllers['password_reset_token_expiry_days']!.text.trim()); + if (tokenDays != null) payload['password_reset_token_expiry_days'] = tokenDays; + final jwtHours = int.tryParse(_controllers['jwt_expiry_hours']!.text.trim()); + if (jwtHours != null) payload['jwt_expiry_hours'] = jwtHours; + final maxMb = int.tryParse(_controllers['max_upload_size_mb']!.text.trim()); + if (maxMb != null) payload['max_upload_size_mb'] = maxMb; + return payload; + } + + Future _save() async { + setState(() { + _message = null; + _isSaving = true; + }); + try { + await ConfigurationService.updateBulk(_buildPayload()); + if (!mounted) return; + setState(() { + _isSaving = false; + _message = 'Configuration enregistrée.'; + }); + } catch (e) { + if (mounted) { + setState(() { + _isSaving = false; + _message = e.toString().replaceAll('Exception: ', ''); + }); + } + } + } + + Future _testSmtp() async { + final email = await showDialog( + context: context, + builder: (ctx) { + final c = TextEditingController(); + return AlertDialog( + title: const Text('Tester la connexion SMTP'), + content: TextField( + controller: c, + decoration: const InputDecoration( + labelText: 'Email pour recevoir le test', + hintText: 'admin@example.com', + ), + keyboardType: TextInputType.emailAddress, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () { + final t = c.text.trim(); + if (t.isNotEmpty) Navigator.pop(ctx, t); + }, + child: const Text('Envoyer'), + ), + ], + ); + }, + ); + if (email == null || !mounted) return; + setState(() => _message = null); + try { + await _save(); + if (!mounted) return; + final msg = await ConfigurationService.testSmtp(email); + if (!mounted) return; + setState(() => _message = msg); + } catch (e) { + if (mounted) { + setState(() => _message = e.toString().replaceAll('Exception: ', '')); + } + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (_loadError != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_loadError!, style: TextStyle(color: Colors.red.shade700)), + const SizedBox(height: 16), + FilledButton( + onPressed: _loadConfiguration, + child: const Text('Réessayer'), + ), + ], + ), + ), + ); + } + + final isSuccess = _message != null && + (_message!.startsWith('Configuration') || _message!.startsWith('Connexion')); + + return Form( + key: _formKey, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_message != null) ...[ + _MessageBanner(message: _message!, isSuccess: isSuccess), + const SizedBox(height: 20), + ], + _buildSectionCard( + context, + icon: Icons.email_outlined, + title: 'Configuration Email (SMTP)', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildField('smtp_host', 'Serveur SMTP', hint: 'mail.example.com'), + const SizedBox(height: 14), + _buildField('smtp_port', 'Port SMTP', keyboard: TextInputType.number, hint: '25, 465, 587'), + const SizedBox(height: 14), + Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Row( + children: [ + Checkbox( + value: _smtpSecure, + onChanged: (v) => setState(() => _smtpSecure = v ?? false), + activeColor: const Color(0xFF9CC5C0), + ), + const Text('SSL/TLS (secure)'), + const SizedBox(width: 24), + Checkbox( + value: _smtpAuthRequired, + onChanged: (v) => setState(() => _smtpAuthRequired = v ?? false), + activeColor: const Color(0xFF9CC5C0), + ), + const Text('Authentification requise'), + ], + ), + ), + _buildField('smtp_user', 'Utilisateur SMTP'), + const SizedBox(height: 14), + _buildField('smtp_password', 'Mot de passe SMTP', obscure: true), + const SizedBox(height: 14), + _buildField('email_from_name', 'Nom expéditeur'), + const SizedBox(height: 14), + _buildField('email_from_address', 'Email expéditeur', hint: 'no-reply@example.com'), + const SizedBox(height: 18), + Align( + alignment: Alignment.centerRight, + child: OutlinedButton.icon( + onPressed: _isSaving ? null : _testSmtp, + icon: const Icon(Icons.send_outlined, size: 18), + label: const Text('Tester la connexion SMTP'), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF2D6A4F), + side: const BorderSide(color: Color(0xFF9CC5C0)), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + _buildSectionCard( + context, + icon: Icons.palette_outlined, + title: 'Personnalisation', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildField('app_name', 'Nom de l\'application'), + const SizedBox(height: 14), + _buildField('app_url', 'URL de l\'application', hint: 'https://app.example.com'), + const SizedBox(height: 14), + _buildField('app_logo_url', 'URL du logo', hint: '/assets/logo.png'), + ], + ), + ), + const SizedBox(height: 24), + _buildSectionCard( + context, + icon: Icons.settings_outlined, + title: 'Paramètres avancés', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildField('password_reset_token_expiry_days', 'Validité token MDP (jours)', keyboard: TextInputType.number), + const SizedBox(height: 14), + _buildField('jwt_expiry_hours', 'Validité session JWT (heures)', keyboard: TextInputType.number), + const SizedBox(height: 14), + _buildField('max_upload_size_mb', 'Taille max upload (MB)', keyboard: TextInputType.number), + ], + ), + ), + const SizedBox(height: 28), + SizedBox( + height: 48, + child: FilledButton( + onPressed: _isSaving ? null : _save, + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF9CC5C0), + foregroundColor: Colors.white, + ), + child: _isSaving + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Text('Sauvegarder la configuration'), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildSectionCard(BuildContext context, {required IconData icon, required String title, required Widget child}) { + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 22, color: const Color(0xFF9CC5C0)), + const SizedBox(width: 10), + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: const Color(0xFF2D6A4F), + ), + ), + ], + ), + const SizedBox(height: 20), + child, + ], + ), + ), + ); + } + + Widget _buildField(String key, String label, {bool obscure = false, TextInputType? keyboard, String? hint}) { + final c = _controllers[key]; + if (c == null) return const SizedBox.shrink(); + return TextFormField( + controller: c, + obscureText: obscure, + keyboardType: keyboard, + enabled: !_isSaving, + decoration: InputDecoration( + labelText: label, + hintText: hint, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + ); + } +} + +class _MessageBanner extends StatelessWidget { + final String message; + final bool isSuccess; + + const _MessageBanner({required this.message, required this.isSuccess}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isSuccess ? Colors.green.shade50 : Colors.red.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isSuccess ? Colors.green.shade200 : Colors.red.shade200, + ), + ), + child: Row( + children: [ + Icon( + isSuccess ? Icons.check_circle_outline : Icons.error_outline, + size: 22, + color: isSuccess ? Colors.green.shade700 : Colors.red.shade700, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: TextStyle( + color: isSuccess ? Colors.green.shade900 : Colors.red.shade900, + fontSize: 14, + ), + ), + ), + ], + ), + ); + } +} From ca7ef862da8b30ec302a75537f8f3b1cd4138650 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 13 Feb 2026 15:54:47 +0100 Subject: [PATCH 05/11] =?UTF-8?q?feat(admin):=20premi=C3=A8re=20connexion?= =?UTF-8?q?=20=E2=86=92=20panneau=20Param=C3=A8tres,=20reste=20gris=C3=A9?= =?UTF-8?q?=20jusqu'=C3=A0=20Sauvegarder=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Au chargement admin: appel getSetupStatus(), si non terminé → onglet Paramètres par défaut - Onglet Gestion des utilisateurs grisé et inaccessible tant que setup non complété - Sauvegarder: updateBulk + completeSetup + déblocage des panneaux - Tester SMTP: saveBulkOnly puis test (sans completeSetup, panneaux restent verrouillés) Co-authored-by: Cursor --- .../admin_dashboardScreen.dart | 33 ++++++++++++++- .../lib/widgets/admin/dashboard_admin.dart | 40 +++++++++++-------- .../lib/widgets/admin/parametres_panel.dart | 16 +++++++- 3 files changed, 69 insertions(+), 20 deletions(-) diff --git a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart index be288bd..ff67e5f 100644 --- a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart +++ b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/services/configuration_service.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart'; @@ -14,12 +15,34 @@ class AdminDashboardScreen extends StatefulWidget { } class _AdminDashboardScreenState extends State { + /// null = chargement du statut setup, true/false = connu + bool? _setupCompleted; + /// 0 = Gestion des utilisateurs, 1 = Paramètres int mainTabIndex = 0; /// Sous-onglet quand mainTabIndex == 0 : 0=Gestionnaires, 1=Parents, 2=AM, 3=Administrateurs int subIndex = 0; + @override + void initState() { + super.initState(); + _loadSetupStatus(); + } + + Future _loadSetupStatus() async { + try { + final completed = await ConfigurationService.getSetupStatus(); + if (!mounted) return; + setState(() { + _setupCompleted = completed; + if (!completed) mainTabIndex = 1; + }); + } catch (_) { + if (mounted) setState(() => _setupCompleted = true); + } + } + void onMainTabChange(int index) { setState(() { mainTabIndex = index; @@ -34,6 +57,11 @@ class _AdminDashboardScreenState extends State { @override Widget build(BuildContext context) { + if (_setupCompleted == null) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } return Scaffold( appBar: PreferredSize( preferredSize: const Size.fromHeight(60.0), @@ -46,6 +74,7 @@ class _AdminDashboardScreenState extends State { child: DashboardAppBarAdmin( selectedIndex: mainTabIndex, onTabChange: onMainTabChange, + setupCompleted: _setupCompleted!, ), ), ), @@ -67,7 +96,9 @@ class _AdminDashboardScreenState extends State { Widget _getBody() { if (mainTabIndex == 1) { - return const ParametresPanel(); + return ParametresPanel( + onSetupCompleted: () => setState(() => _setupCompleted = true), + ); } switch (subIndex) { case 0: diff --git a/frontend/lib/widgets/admin/dashboard_admin.dart b/frontend/lib/widgets/admin/dashboard_admin.dart index d19030a..c32644c 100644 --- a/frontend/lib/widgets/admin/dashboard_admin.dart +++ b/frontend/lib/widgets/admin/dashboard_admin.dart @@ -4,11 +4,14 @@ import 'package:flutter/material.dart'; class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidget { final int selectedIndex; final ValueChanged onTabChange; + /// Si false, l'onglet "Gestion des utilisateurs" est grisé et inaccessible. + final bool setupCompleted; const DashboardAppBarAdmin({ Key? key, required this.selectedIndex, required this.onTabChange, + this.setupCompleted = true, }) : super(key: key); @override @@ -32,9 +35,9 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge child: Row( mainAxisSize: MainAxisSize.min, children: [ - _buildNavItem(context, 'Gestion des utilisateurs', 0), + _buildNavItem(context, 'Gestion des utilisateurs', 0, enabled: setupCompleted), const SizedBox(width: 24), - _buildNavItem(context, 'Paramètres', 1), + _buildNavItem(context, 'Paramètres', 1, enabled: true), ], ), ), @@ -74,23 +77,26 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge ); } - Widget _buildNavItem(BuildContext context, String title, int index) { + Widget _buildNavItem(BuildContext context, String title, int index, {bool enabled = true}) { final bool isActive = index == selectedIndex; return InkWell( - onTap: () => onTabChange(index), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, - borderRadius: BorderRadius.circular(20), - border: isActive ? null : Border.all(color: Colors.black26), - ), - child: Text( - title, - style: TextStyle( - color: isActive ? Colors.white : Colors.black, - fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, - fontSize: 14, + onTap: enabled ? () => onTabChange(index) : null, + child: Opacity( + opacity: enabled ? 1.0 : 0.5, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, + borderRadius: BorderRadius.circular(20), + border: isActive ? null : Border.all(color: Colors.black26), + ), + child: Text( + title, + style: TextStyle( + color: isActive ? Colors.white : Colors.black, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + fontSize: 14, + ), ), ), ), diff --git a/frontend/lib/widgets/admin/parametres_panel.dart b/frontend/lib/widgets/admin/parametres_panel.dart index c50ecdd..c7c6fe0 100644 --- a/frontend/lib/widgets/admin/parametres_panel.dart +++ b/frontend/lib/widgets/admin/parametres_panel.dart @@ -3,7 +3,10 @@ import 'package:p_tits_pas/services/configuration_service.dart'; /// Panneau Paramètres / Configuration (ticket #15) : 3 sections sur une page. class ParametresPanel extends StatefulWidget { - const ParametresPanel({super.key}); + /// Appelé après une sauvegarde réussie (pour débloquer le reste du dashboard si config initiale). + final VoidCallback? onSetupCompleted; + + const ParametresPanel({super.key, this.onSetupCompleted}); @override State createState() => _ParametresPanelState(); @@ -104,6 +107,12 @@ class _ParametresPanelState extends State { return payload; } + /// Enregistre en base sans marquer la config initiale comme terminée (utilisé avant test SMTP). + Future _saveBulkOnly() async { + await ConfigurationService.updateBulk(_buildPayload()); + } + + /// Sauvegarde + marque la config initiale comme terminée + débloque les panneaux. Future _save() async { setState(() { _message = null; @@ -112,6 +121,9 @@ class _ParametresPanelState extends State { try { await ConfigurationService.updateBulk(_buildPayload()); if (!mounted) return; + await ConfigurationService.completeSetup(); + if (!mounted) return; + widget.onSetupCompleted?.call(); setState(() { _isSaving = false; _message = 'Configuration enregistrée.'; @@ -160,7 +172,7 @@ class _ParametresPanelState extends State { if (email == null || !mounted) return; setState(() => _message = null); try { - await _save(); + await _saveBulkOnly(); if (!mounted) return; final msg = await ConfigurationService.testSmtp(email); if (!mounted) return; From 31857ec89130cedca9c4720d7b7d664ecda1aa57 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 13 Feb 2026 16:10:04 +0100 Subject: [PATCH 06/11] docs(#14): note back config/setup + frontend parsing erreurs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/14_NOTE-BACKEND-CONFIG-SETUP.md : modifs à faire côté back (UUID system) - configuration_service : parsing défensif des réponses d'erreur (évite JSNull) Co-authored-by: Cursor --- docs/14_NOTE-BACKEND-CONFIG-SETUP.md | 37 +++++++++++++++++++ .../lib/services/configuration_service.dart | 19 ++++++---- 2 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 docs/14_NOTE-BACKEND-CONFIG-SETUP.md diff --git a/docs/14_NOTE-BACKEND-CONFIG-SETUP.md b/docs/14_NOTE-BACKEND-CONFIG-SETUP.md new file mode 100644 index 0000000..f1716d9 --- /dev/null +++ b/docs/14_NOTE-BACKEND-CONFIG-SETUP.md @@ -0,0 +1,37 @@ +# Ticket #14 – Note pour modifications backend + +**Contexte :** Première connexion admin → panneau Paramètres, déblocage après clic sur « Sauvegarder ». Le front appelle `POST /api/v1/configuration/setup/complete` au clic sur Sauvegarder. + +## Problème + +Erreur renvoyée par le back : +`invalid input syntax for type uuid: "system"` + +- Le controller fait `const userId = req.user?.id || 'system'` puis `markSetupCompleted(userId)`. +- Le service `set()` fait `config.modifiePar = { id: userId }` ; la colonne `modifie_par` est une FK UUID vers `users`. +- La chaîne `"system"` n’est pas un UUID valide → erreur PostgreSQL. + +## Modifications à apporter au backend + +**Option A – Accepter l’absence d’utilisateur (recommandé si la route peut être appelée sans JWT)** + +1. **`config.controller.ts`** (route `completeSetup`) + - Remplacer : + `const userId = req.user?.id || 'system';` + - Par : + `const userId = req.user?.id ?? null;` + +2. **`config.service.ts`** (`markSetupCompleted`) + - Changer la signature : + `async markSetupCompleted(userId: string | null): Promise` + - Et appeler : + `await this.set('setup_completed', 'true', userId ?? undefined);` + - Dans `set()`, ne pas remplir `modifiePar` quand `userId` est absent (déjà le cas si `if (userId)`). + +**Option B – Imposer un utilisateur authentifié** + +- Activer le guard JWT (et éventuellement RolesGuard) sur `POST /configuration/setup/complete` pour que `req.user` soit toujours défini, et garder `userId = req.user.id` (plus de fallback `'system'`). + +--- + +Une fois le back modifié, le flux « Sauvegarder » → déblocage des panneaux fonctionne sans erreur. diff --git a/frontend/lib/services/configuration_service.dart b/frontend/lib/services/configuration_service.dart index 8f1c905..52cd407 100644 --- a/frontend/lib/services/configuration_service.dart +++ b/frontend/lib/services/configuration_service.dart @@ -106,8 +106,9 @@ class ConfigurationService { body: jsonEncode(body), ); if (response.statusCode != 200) { - final err = jsonDecode(response.body) as Map; - throw Exception(err['message'] ?? 'Erreur lors de la sauvegarde'); + final err = jsonDecode(response.body) as Map?; + final msg = err != null ? (err['message'] as String? ?? err['error'] as String?) : null; + throw Exception(msg ?? 'Erreur lors de la sauvegarde'); } } @@ -118,11 +119,12 @@ class ConfigurationService { headers: await _headers(), body: jsonEncode({'testEmail': testEmail}), ); - final data = jsonDecode(response.body) as Map; - if (response.statusCode == 200 && data['success'] == true) { - return data['message'] as String? ?? 'Test SMTP réussi.'; + final data = jsonDecode(response.body) as Map?; + if (response.statusCode == 200 && (data?['success'] == true)) { + return data!['message'] as String? ?? 'Test SMTP réussi.'; } - throw Exception(data['error'] ?? data['message'] ?? 'Échec du test SMTP'); + final msg = data != null ? (data['error'] as String? ?? data['message'] as String?) : null; + throw Exception(msg ?? 'Échec du test SMTP'); } /// POST /api/v1/configuration/setup/complete (après première config) @@ -132,8 +134,9 @@ class ConfigurationService { headers: await _headers(), ); if (response.statusCode != 200) { - final err = jsonDecode(response.body) as Map; - throw Exception(err['message'] ?? 'Erreur finalisation configuration'); + final err = jsonDecode(response.body) as Map?; + final msg = err != null ? (err['message'] as String? ?? err['error'] as String?) : null; + throw Exception(msg ?? 'Erreur finalisation configuration'); } } } From 6752dc97b4291fc2834427b5886056ce8a4721c2 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sun, 15 Feb 2026 23:02:12 +0100 Subject: [PATCH 07/11] =?UTF-8?q?feat(#14):=20redirection=20premi=C3=A8re?= =?UTF-8?q?=20connexion=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Redirection vers /login après première config réussie - Gestion défensive des réponses API (200/201, bool/string) - Force l'onglet Paramètres si setup non terminé Co-authored-by: Cursor --- frontend/lib/config/env.dart | 2 +- .../admin_dashboardScreen.dart | 16 ++++----- .../lib/services/configuration_service.dart | 36 +++++++++++-------- .../lib/widgets/admin/dashboard_admin.dart | 12 ++++--- .../lib/widgets/admin/parametres_panel.dart | 19 ++++++---- 5 files changed, 48 insertions(+), 37 deletions(-) diff --git a/frontend/lib/config/env.dart b/frontend/lib/config/env.dart index 34e1cbd..fb9c0e1 100644 --- a/frontend/lib/config/env.dart +++ b/frontend/lib/config/env.dart @@ -6,7 +6,7 @@ class Env { ); // Construit une URL vers l'API v1 à partir d'un chemin (commençant par '/') - static String apiV1(String path) => "${apiBaseUrl}/api/v1$path"; + static String apiV1(String path) => '$apiBaseUrl/api/v1$path'; } diff --git a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart index ff67e5f..1868c9d 100644 --- a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart +++ b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart @@ -15,13 +15,8 @@ class AdminDashboardScreen extends StatefulWidget { } class _AdminDashboardScreenState extends State { - /// null = chargement du statut setup, true/false = connu bool? _setupCompleted; - - /// 0 = Gestion des utilisateurs, 1 = Paramètres int mainTabIndex = 0; - - /// Sous-onglet quand mainTabIndex == 0 : 0=Gestionnaires, 1=Parents, 2=AM, 3=Administrateurs int subIndex = 0; @override @@ -38,8 +33,11 @@ class _AdminDashboardScreenState extends State { _setupCompleted = completed; if (!completed) mainTabIndex = 1; }); - } catch (_) { - if (mounted) setState(() => _setupCompleted = true); + } catch (e) { + if (mounted) setState(() { + _setupCompleted = false; + mainTabIndex = 1; + }); } } @@ -96,9 +94,7 @@ class _AdminDashboardScreenState extends State { Widget _getBody() { if (mainTabIndex == 1) { - return ParametresPanel( - onSetupCompleted: () => setState(() => _setupCompleted = true), - ); + return ParametresPanel(redirectToLoginAfterSave: !_setupCompleted!); } switch (subIndex) { case 0: diff --git a/frontend/lib/services/configuration_service.dart b/frontend/lib/services/configuration_service.dart index 52cd407..21e987e 100644 --- a/frontend/lib/services/configuration_service.dart +++ b/frontend/lib/services/configuration_service.dart @@ -55,6 +55,12 @@ class ConfigurationService { : Map.from(ApiConfig.headers); } + static String? _toStr(dynamic v) { + if (v == null) return null; + if (v is String) return v; + return v.toString(); + } + /// GET /api/v1/configuration/setup/status static Future getSetupStatus() async { final response = await http.get( @@ -63,7 +69,11 @@ class ConfigurationService { ); if (response.statusCode != 200) return true; final data = jsonDecode(response.body); - return data['data']?['setupCompleted'] as bool? ?? true; + final val = data['data']?['setupCompleted']; + if (val is bool) return val; + if (val is String) return val.toLowerCase() == 'true' || val == '1'; + if (val is int) return val == 1; + return true; // Par défaut on considère configuré pour ne pas bloquer } /// GET /api/v1/configuration (toutes les configs) @@ -73,9 +83,8 @@ class ConfigurationService { headers: await _headers(), ); if (response.statusCode != 200) { - throw Exception( - (jsonDecode(response.body) as Map)['message'] ?? 'Erreur chargement configuration', - ); + final err = jsonDecode(response.body) as Map?; + throw Exception(_toStr(err?['message']) ?? 'Erreur chargement configuration'); } final data = jsonDecode(response.body); final list = data['data'] as List? ?? []; @@ -89,9 +98,8 @@ class ConfigurationService { headers: await _headers(), ); if (response.statusCode != 200) { - throw Exception( - (jsonDecode(response.body) as Map)['message'] ?? 'Erreur chargement configuration', - ); + final err = jsonDecode(response.body) as Map?; + throw Exception(_toStr(err?['message']) ?? 'Erreur chargement configuration'); } final data = jsonDecode(response.body); final map = data['data'] as Map? ?? {}; @@ -105,9 +113,9 @@ class ConfigurationService { headers: await _headers(), body: jsonEncode(body), ); - if (response.statusCode != 200) { + if (response.statusCode != 200 && response.statusCode != 201) { final err = jsonDecode(response.body) as Map?; - final msg = err != null ? (err['message'] as String? ?? err['error'] as String?) : null; + final msg = err != null ? (_toStr(err['error']) ?? _toStr(err['message'])) : null; throw Exception(msg ?? 'Erreur lors de la sauvegarde'); } } @@ -120,10 +128,10 @@ class ConfigurationService { body: jsonEncode({'testEmail': testEmail}), ); final data = jsonDecode(response.body) as Map?; - if (response.statusCode == 200 && (data?['success'] == true)) { - return data!['message'] as String? ?? 'Test SMTP réussi.'; + if ((response.statusCode == 200 || response.statusCode == 201) && (data?['success'] == true)) { + return _toStr(data?['message']) ?? 'Test SMTP réussi.'; } - final msg = data != null ? (data['error'] as String? ?? data['message'] as String?) : null; + final msg = data != null ? (_toStr(data['error']) ?? _toStr(data['message'])) : null; throw Exception(msg ?? 'Échec du test SMTP'); } @@ -133,9 +141,9 @@ class ConfigurationService { Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationSetupComplete}'), headers: await _headers(), ); - if (response.statusCode != 200) { + if (response.statusCode != 200 && response.statusCode != 201) { final err = jsonDecode(response.body) as Map?; - final msg = err != null ? (err['message'] as String? ?? err['error'] as String?) : null; + final msg = err != null ? (_toStr(err['error']) ?? _toStr(err['message'])) : null; throw Exception(msg ?? 'Erreur finalisation configuration'); } } diff --git a/frontend/lib/widgets/admin/dashboard_admin.dart b/frontend/lib/widgets/admin/dashboard_admin.dart index c32644c..12fbe8b 100644 --- a/frontend/lib/widgets/admin/dashboard_admin.dart +++ b/frontend/lib/widgets/admin/dashboard_admin.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:p_tits_pas/services/auth_service.dart'; -/// Barre principale du dashboard admin : 2 onglets (Gestion des utilisateurs | Paramètres) + infos utilisateur. +/// Barre du dashboard admin : onglets Gestion des utilisateurs | Paramètres + déconnexion. class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidget { final int selectedIndex; final ValueChanged onTabChange; - /// Si false, l'onglet "Gestion des utilisateurs" est grisé et inaccessible. final bool setupCompleted; const DashboardAppBarAdmin({ @@ -115,9 +116,10 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge child: const Text('Annuler'), ), ElevatedButton( - onPressed: () { + onPressed: () async { Navigator.pop(context); - // TODO: Implémenter la logique de déconnexion + await AuthService.logout(); + if (context.mounted) context.go('/login'); }, child: const Text('Déconnecter'), ), @@ -127,7 +129,7 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge } } -/// Sous-barre affichée quand "Gestion des utilisateurs" est actif : 4 onglets sans infos utilisateur. +/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | Administrateurs. class DashboardUserManagementSubBar extends StatelessWidget { final int selectedSubIndex; final ValueChanged onSubTabChange; diff --git a/frontend/lib/widgets/admin/parametres_panel.dart b/frontend/lib/widgets/admin/parametres_panel.dart index c7c6fe0..22e7b7c 100644 --- a/frontend/lib/widgets/admin/parametres_panel.dart +++ b/frontend/lib/widgets/admin/parametres_panel.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:p_tits_pas/services/configuration_service.dart'; -/// Panneau Paramètres / Configuration (ticket #15) : 3 sections sur une page. +/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé. class ParametresPanel extends StatefulWidget { - /// Appelé après une sauvegarde réussie (pour débloquer le reste du dashboard si config initiale). - final VoidCallback? onSetupCompleted; + /// Si true, après sauvegarde on redirige vers le login (première config). Sinon on reste sur la page. + final bool redirectToLoginAfterSave; - const ParametresPanel({super.key, this.onSetupCompleted}); + const ParametresPanel({super.key, this.redirectToLoginAfterSave = false}); @override State createState() => _ParametresPanelState(); @@ -107,13 +108,14 @@ class _ParametresPanelState extends State { return payload; } - /// Enregistre en base sans marquer la config initiale comme terminée (utilisé avant test SMTP). + /// Sauvegarde en base sans completeSetup (utilisé avant test SMTP). Future _saveBulkOnly() async { await ConfigurationService.updateBulk(_buildPayload()); } - /// Sauvegarde + marque la config initiale comme terminée + débloque les panneaux. + /// Sauvegarde la config, marque le setup comme terminé. Si première config, redirige vers le login. Future _save() async { + final redirectAfter = widget.redirectToLoginAfterSave; setState(() { _message = null; _isSaving = true; @@ -123,11 +125,14 @@ class _ParametresPanelState extends State { if (!mounted) return; await ConfigurationService.completeSetup(); if (!mounted) return; - widget.onSetupCompleted?.call(); setState(() { _isSaving = false; _message = 'Configuration enregistrée.'; }); + if (!mounted) return; + if (redirectAfter) { + GoRouter.of(context).go('/login'); + } } catch (e) { if (mounted) { setState(() { From 8e8c6d79b1800cc226650311e69e6b3e603493f7 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sun, 15 Feb 2026 23:08:02 +0100 Subject: [PATCH 08/11] feat(#14): finalisation redirection et nettoyage Co-authored-by: Cursor --- .../admin_dashboardScreen.dart | 16 ++++----- .../lib/services/configuration_service.dart | 36 +++++++++++-------- .../lib/widgets/admin/parametres_panel.dart | 19 ++++++---- 3 files changed, 40 insertions(+), 31 deletions(-) diff --git a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart index ff67e5f..1868c9d 100644 --- a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart +++ b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart @@ -15,13 +15,8 @@ class AdminDashboardScreen extends StatefulWidget { } class _AdminDashboardScreenState extends State { - /// null = chargement du statut setup, true/false = connu bool? _setupCompleted; - - /// 0 = Gestion des utilisateurs, 1 = Paramètres int mainTabIndex = 0; - - /// Sous-onglet quand mainTabIndex == 0 : 0=Gestionnaires, 1=Parents, 2=AM, 3=Administrateurs int subIndex = 0; @override @@ -38,8 +33,11 @@ class _AdminDashboardScreenState extends State { _setupCompleted = completed; if (!completed) mainTabIndex = 1; }); - } catch (_) { - if (mounted) setState(() => _setupCompleted = true); + } catch (e) { + if (mounted) setState(() { + _setupCompleted = false; + mainTabIndex = 1; + }); } } @@ -96,9 +94,7 @@ class _AdminDashboardScreenState extends State { Widget _getBody() { if (mainTabIndex == 1) { - return ParametresPanel( - onSetupCompleted: () => setState(() => _setupCompleted = true), - ); + return ParametresPanel(redirectToLoginAfterSave: !_setupCompleted!); } switch (subIndex) { case 0: diff --git a/frontend/lib/services/configuration_service.dart b/frontend/lib/services/configuration_service.dart index 52cd407..21e987e 100644 --- a/frontend/lib/services/configuration_service.dart +++ b/frontend/lib/services/configuration_service.dart @@ -55,6 +55,12 @@ class ConfigurationService { : Map.from(ApiConfig.headers); } + static String? _toStr(dynamic v) { + if (v == null) return null; + if (v is String) return v; + return v.toString(); + } + /// GET /api/v1/configuration/setup/status static Future getSetupStatus() async { final response = await http.get( @@ -63,7 +69,11 @@ class ConfigurationService { ); if (response.statusCode != 200) return true; final data = jsonDecode(response.body); - return data['data']?['setupCompleted'] as bool? ?? true; + final val = data['data']?['setupCompleted']; + if (val is bool) return val; + if (val is String) return val.toLowerCase() == 'true' || val == '1'; + if (val is int) return val == 1; + return true; // Par défaut on considère configuré pour ne pas bloquer } /// GET /api/v1/configuration (toutes les configs) @@ -73,9 +83,8 @@ class ConfigurationService { headers: await _headers(), ); if (response.statusCode != 200) { - throw Exception( - (jsonDecode(response.body) as Map)['message'] ?? 'Erreur chargement configuration', - ); + final err = jsonDecode(response.body) as Map?; + throw Exception(_toStr(err?['message']) ?? 'Erreur chargement configuration'); } final data = jsonDecode(response.body); final list = data['data'] as List? ?? []; @@ -89,9 +98,8 @@ class ConfigurationService { headers: await _headers(), ); if (response.statusCode != 200) { - throw Exception( - (jsonDecode(response.body) as Map)['message'] ?? 'Erreur chargement configuration', - ); + final err = jsonDecode(response.body) as Map?; + throw Exception(_toStr(err?['message']) ?? 'Erreur chargement configuration'); } final data = jsonDecode(response.body); final map = data['data'] as Map? ?? {}; @@ -105,9 +113,9 @@ class ConfigurationService { headers: await _headers(), body: jsonEncode(body), ); - if (response.statusCode != 200) { + if (response.statusCode != 200 && response.statusCode != 201) { final err = jsonDecode(response.body) as Map?; - final msg = err != null ? (err['message'] as String? ?? err['error'] as String?) : null; + final msg = err != null ? (_toStr(err['error']) ?? _toStr(err['message'])) : null; throw Exception(msg ?? 'Erreur lors de la sauvegarde'); } } @@ -120,10 +128,10 @@ class ConfigurationService { body: jsonEncode({'testEmail': testEmail}), ); final data = jsonDecode(response.body) as Map?; - if (response.statusCode == 200 && (data?['success'] == true)) { - return data!['message'] as String? ?? 'Test SMTP réussi.'; + if ((response.statusCode == 200 || response.statusCode == 201) && (data?['success'] == true)) { + return _toStr(data?['message']) ?? 'Test SMTP réussi.'; } - final msg = data != null ? (data['error'] as String? ?? data['message'] as String?) : null; + final msg = data != null ? (_toStr(data['error']) ?? _toStr(data['message'])) : null; throw Exception(msg ?? 'Échec du test SMTP'); } @@ -133,9 +141,9 @@ class ConfigurationService { Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationSetupComplete}'), headers: await _headers(), ); - if (response.statusCode != 200) { + if (response.statusCode != 200 && response.statusCode != 201) { final err = jsonDecode(response.body) as Map?; - final msg = err != null ? (err['message'] as String? ?? err['error'] as String?) : null; + final msg = err != null ? (_toStr(err['error']) ?? _toStr(err['message'])) : null; throw Exception(msg ?? 'Erreur finalisation configuration'); } } diff --git a/frontend/lib/widgets/admin/parametres_panel.dart b/frontend/lib/widgets/admin/parametres_panel.dart index c7c6fe0..22e7b7c 100644 --- a/frontend/lib/widgets/admin/parametres_panel.dart +++ b/frontend/lib/widgets/admin/parametres_panel.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:p_tits_pas/services/configuration_service.dart'; -/// Panneau Paramètres / Configuration (ticket #15) : 3 sections sur une page. +/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé. class ParametresPanel extends StatefulWidget { - /// Appelé après une sauvegarde réussie (pour débloquer le reste du dashboard si config initiale). - final VoidCallback? onSetupCompleted; + /// Si true, après sauvegarde on redirige vers le login (première config). Sinon on reste sur la page. + final bool redirectToLoginAfterSave; - const ParametresPanel({super.key, this.onSetupCompleted}); + const ParametresPanel({super.key, this.redirectToLoginAfterSave = false}); @override State createState() => _ParametresPanelState(); @@ -107,13 +108,14 @@ class _ParametresPanelState extends State { return payload; } - /// Enregistre en base sans marquer la config initiale comme terminée (utilisé avant test SMTP). + /// Sauvegarde en base sans completeSetup (utilisé avant test SMTP). Future _saveBulkOnly() async { await ConfigurationService.updateBulk(_buildPayload()); } - /// Sauvegarde + marque la config initiale comme terminée + débloque les panneaux. + /// Sauvegarde la config, marque le setup comme terminé. Si première config, redirige vers le login. Future _save() async { + final redirectAfter = widget.redirectToLoginAfterSave; setState(() { _message = null; _isSaving = true; @@ -123,11 +125,14 @@ class _ParametresPanelState extends State { if (!mounted) return; await ConfigurationService.completeSetup(); if (!mounted) return; - widget.onSetupCompleted?.call(); setState(() { _isSaving = false; _message = 'Configuration enregistrée.'; }); + if (!mounted) return; + if (redirectAfter) { + GoRouter.of(context).go('/login'); + } } catch (e) { if (mounted) { setState(() { From ae3292a7fc14f5a76402c6b4d7ae809592cc808f Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sun, 15 Feb 2026 23:19:18 +0100 Subject: [PATCH 09/11] =?UTF-8?q?fix(backend):=20setup/complete=20accepte?= =?UTF-8?q?=20userId=20null=20pour=20=C3=A9viter=20erreur=20UUID=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - completeSetup: userId = req.user?.id ?? null (plus de fallback 'system') - markSetupCompleted(userId: string | null), set(..., userId ?? undefined) - Corrige 'invalid input syntax for type uuid: "system"' au clic Sauvegarder Co-authored-by: Cursor --- backend/src/modules/config/config.controller.ts | 3 +-- backend/src/modules/config/config.service.ts | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/backend/src/modules/config/config.controller.ts b/backend/src/modules/config/config.controller.ts index ee1c9ba..701bb48 100644 --- a/backend/src/modules/config/config.controller.ts +++ b/backend/src/modules/config/config.controller.ts @@ -53,8 +53,7 @@ export class ConfigController { // @Roles('super_admin') async completeSetup(@Request() req: any) { try { - // TODO: Récupérer l'ID utilisateur depuis le JWT - const userId = req.user?.id || 'system'; + const userId = req.user?.id ?? null; await this.configService.markSetupCompleted(userId); diff --git a/backend/src/modules/config/config.service.ts b/backend/src/modules/config/config.service.ts index 421ccae..973546b 100644 --- a/backend/src/modules/config/config.service.ts +++ b/backend/src/modules/config/config.service.ts @@ -259,10 +259,10 @@ export class AppConfigService implements OnModuleInit { /** * Marquer la configuration initiale comme terminée - * @param userId ID de l'utilisateur qui termine la configuration + * @param userId ID de l'utilisateur qui termine la configuration (null si non authentifié) */ - async markSetupCompleted(userId: string): Promise { - await this.set('setup_completed', 'true', userId); + async markSetupCompleted(userId: string | null): Promise { + await this.set('setup_completed', 'true', userId ?? undefined); this.logger.log('✅ Configuration initiale marquée comme terminée'); } From c94f2cf0d5dd53882a534a4f032e936d16cdb312 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 16 Feb 2026 00:05:23 +0100 Subject: [PATCH 10/11] feat(#90): API Inscription AM - POST /auth/register/am MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DTO RegisterAMCompletDto (identité, photo, infos pro, CGU) - Endpoint POST /auth/register/am + inscrireAMComplet() (transaction User + AssistanteMaternelle) - Photo base64, token création MDP, consentement photo - Suppression legacy: route register/parent/legacy, registerParent(), RegisterParentDto - Frontend: ApiConfig.registerAM pour ticket #91 Co-authored-by: Cursor --- backend/src/routes/auth/auth.controller.ts | 16 +- backend/src/routes/auth/auth.module.ts | 3 +- backend/src/routes/auth/auth.service.ts | 200 +++++++----------- .../auth/dto/register-am-complet.dto.ts | 156 ++++++++++++++ .../routes/auth/dto/register-parent.dto.ts | 105 --------- frontend/lib/services/api/api_config.dart | 2 + 6 files changed, 249 insertions(+), 233 deletions(-) create mode 100644 backend/src/routes/auth/dto/register-am-complet.dto.ts delete mode 100644 backend/src/routes/auth/dto/register-parent.dto.ts diff --git a/backend/src/routes/auth/auth.controller.ts b/backend/src/routes/auth/auth.controller.ts index 3de3fc1..2eeaf98 100644 --- a/backend/src/routes/auth/auth.controller.ts +++ b/backend/src/routes/auth/auth.controller.ts @@ -3,8 +3,8 @@ import { LoginDto } from './dto/login.dto'; import { AuthService } from './auth.service'; import { Public } from 'src/common/decorators/public.decorator'; import { RegisterDto } from './dto/register.dto'; -import { RegisterParentDto } from './dto/register-parent.dto'; import { RegisterParentCompletDto } from './dto/register-parent-complet.dto'; +import { RegisterAMCompletDto } from './dto/register-am-complet.dto'; import { ChangePasswordRequiredDto } from './dto/change-password.dto'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AuthGuard } from 'src/common/guards/auth.guard'; @@ -53,12 +53,16 @@ export class AuthController { } @Public() - @Post('register/parent/legacy') - @ApiOperation({ summary: '[OBSOLÈTE] Inscription Parent (étape 1/6 uniquement)' }) - @ApiResponse({ status: 201, description: 'Inscription réussie' }) + @Post('register/am') + @ApiOperation({ + summary: 'Inscription Assistante Maternelle COMPLÈTE', + description: 'Crée User AM + entrée assistantes_maternelles (identité + infos pro + photo + CGU) en une transaction', + }) + @ApiResponse({ status: 201, description: 'Inscription réussie - Dossier en attente de validation' }) + @ApiResponse({ status: 400, description: 'Données invalides ou CGU non acceptées' }) @ApiResponse({ status: 409, description: 'Email déjà utilisé' }) - async registerParentLegacy(@Body() dto: RegisterParentDto) { - return this.authService.registerParent(dto); + async inscrireAMComplet(@Body() dto: RegisterAMCompletDto) { + return this.authService.inscrireAMComplet(dto); } @Public() diff --git a/backend/src/routes/auth/auth.module.ts b/backend/src/routes/auth/auth.module.ts index 6554be7..3d15615 100644 --- a/backend/src/routes/auth/auth.module.ts +++ b/backend/src/routes/auth/auth.module.ts @@ -8,11 +8,12 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { Users } from 'src/entities/users.entity'; import { Parents } from 'src/entities/parents.entity'; import { Children } from 'src/entities/children.entity'; +import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AppConfigModule } from 'src/modules/config'; @Module({ imports: [ - TypeOrmModule.forFeature([Users, Parents, Children]), + TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]), forwardRef(() => UserModule), AppConfigModule, JwtModule.registerAsync({ diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 1c9985e..1fdb8cd 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -13,13 +13,14 @@ import * as crypto from 'crypto'; import * as fs from 'fs/promises'; import * as path from 'path'; import { RegisterDto } from './dto/register.dto'; -import { RegisterParentDto } from './dto/register-parent.dto'; import { RegisterParentCompletDto } from './dto/register-parent-complet.dto'; +import { RegisterAMCompletDto } from './dto/register-am-complet.dto'; import { ConfigService } from '@nestjs/config'; import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity'; import { Parents } from 'src/entities/parents.entity'; import { Children, StatutEnfantType } from 'src/entities/children.entity'; import { ParentsChildren } from 'src/entities/parents_children.entity'; +import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { LoginDto } from './dto/login.dto'; import { AppConfigService } from 'src/modules/config/config.service'; @@ -116,7 +117,7 @@ export class AuthService { } /** - * Inscription utilisateur OBSOLÈTE - Utiliser registerParent() ou registerAM() + * Inscription utilisateur OBSOLÈTE - Utiliser inscrireParentComplet() ou registerAM() * @deprecated */ async register(registerDto: RegisterDto) { @@ -157,125 +158,6 @@ export class AuthService { }; } - /** - * Inscription Parent (étape 1/6 du workflow CDC) - * SANS mot de passe - Token de création MDP généré - */ - async registerParent(dto: RegisterParentDto) { - // 1. Vérifier que l'email n'existe pas - const exists = await this.usersService.findByEmailOrNull(dto.email); - if (exists) { - throw new ConflictException('Un compte avec cet email existe déjà'); - } - - // 2. Vérifier l'email du co-parent s'il existe - if (dto.co_parent_email) { - const coParentExists = await this.usersService.findByEmailOrNull(dto.co_parent_email); - if (coParentExists) { - throw new ConflictException('L\'email du co-parent est déjà utilisé'); - } - } - - // 3. Récupérer la durée d'expiration du token depuis la config - const tokenExpiryDays = await this.appConfigService.get( - 'password_reset_token_expiry_days', - 7, - ); - - // 4. Générer les tokens de création de mot de passe - const tokenCreationMdp = crypto.randomUUID(); - const tokenExpiration = new Date(); - tokenExpiration.setDate(tokenExpiration.getDate() + tokenExpiryDays); - - // 5. Transaction : Créer Parent 1 + Parent 2 (si existe) + entités parents - const result = await this.usersRepo.manager.transaction(async (manager) => { - // Créer Parent 1 - const parent1 = manager.create(Users, { - email: dto.email, - prenom: dto.prenom, - nom: dto.nom, - role: RoleType.PARENT, - statut: StatutUtilisateurType.EN_ATTENTE, - telephone: dto.telephone, - adresse: dto.adresse, - code_postal: dto.code_postal, - ville: dto.ville, - token_creation_mdp: tokenCreationMdp, - token_creation_mdp_expire_le: tokenExpiration, - }); - - const savedParent1 = await manager.save(Users, parent1); - - // Créer Parent 2 si renseigné - let savedParent2: Users | null = null; - let tokenCoParent: string | null = null; - - if (dto.co_parent_email && dto.co_parent_prenom && dto.co_parent_nom) { - tokenCoParent = crypto.randomUUID(); - const tokenExpirationCoParent = new Date(); - tokenExpirationCoParent.setDate(tokenExpirationCoParent.getDate() + tokenExpiryDays); - - const parent2 = manager.create(Users, { - email: dto.co_parent_email, - prenom: dto.co_parent_prenom, - nom: dto.co_parent_nom, - role: RoleType.PARENT, - statut: StatutUtilisateurType.EN_ATTENTE, - telephone: dto.co_parent_telephone, - adresse: dto.co_parent_meme_adresse ? dto.adresse : dto.co_parent_adresse, - code_postal: dto.co_parent_meme_adresse ? dto.code_postal : dto.co_parent_code_postal, - ville: dto.co_parent_meme_adresse ? dto.ville : dto.co_parent_ville, - token_creation_mdp: tokenCoParent, - token_creation_mdp_expire_le: tokenExpirationCoParent, - }); - - savedParent2 = await manager.save(Users, parent2); - } - - // Créer l'entité métier Parents pour Parent 1 - const parentEntity = manager.create(Parents, { - user_id: savedParent1.id, - }); - parentEntity.user = savedParent1; - if (savedParent2) { - parentEntity.co_parent = savedParent2; - } - - await manager.save(Parents, parentEntity); - - // Créer l'entité métier Parents pour Parent 2 (si existe) - if (savedParent2) { - const coParentEntity = manager.create(Parents, { - user_id: savedParent2.id, - }); - coParentEntity.user = savedParent2; - coParentEntity.co_parent = savedParent1; - - await manager.save(Parents, coParentEntity); - } - - return { - parent1: savedParent1, - parent2: savedParent2, - tokenCreationMdp, - tokenCoParent, - }; - }); - - // 6. TODO: Envoyer email avec lien de création de MDP - // await this.mailService.sendPasswordCreationEmail(result.parent1, result.tokenCreationMdp); - // if (result.parent2 && result.tokenCoParent) { - // await this.mailService.sendPasswordCreationEmail(result.parent2, result.tokenCoParent); - // } - - return { - message: 'Inscription réussie. Un email de validation vous a été envoyé.', - parent_id: result.parent1.id, - co_parent_id: result.parent2?.id, - statut: StatutUtilisateurType.EN_ATTENTE, - }; - } - /** * Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction * Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU @@ -432,6 +314,82 @@ export class AuthService { }; } + /** + * Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU) + * Crée User (role AM) + entrée assistantes_maternelles, token création MDP + */ + async inscrireAMComplet(dto: RegisterAMCompletDto) { + if (!dto.acceptation_cgu || !dto.acceptation_privacy) { + throw new BadRequestException( + "L'acceptation des CGU et de la politique de confidentialité est obligatoire", + ); + } + + const existe = await this.usersService.findByEmailOrNull(dto.email); + if (existe) { + throw new ConflictException('Un compte avec cet email existe déjà'); + } + + const joursExpirationToken = await this.appConfigService.get( + 'password_reset_token_expiry_days', + 7, + ); + const tokenCreationMdp = crypto.randomUUID(); + const dateExpiration = new Date(); + dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken); + + let urlPhoto: string | null = null; + if (dto.photo_base64 && dto.photo_filename) { + urlPhoto = await this.sauvegarderPhotoDepuisBase64(dto.photo_base64, dto.photo_filename); + } + + const dateConsentementPhoto = + dto.consentement_photo ? new Date() : undefined; + + const resultat = await this.usersRepo.manager.transaction(async (manager) => { + const user = manager.create(Users, { + email: dto.email, + prenom: dto.prenom, + nom: dto.nom, + role: RoleType.ASSISTANTE_MATERNELLE, + statut: StatutUtilisateurType.EN_ATTENTE, + telephone: dto.telephone, + adresse: dto.adresse, + code_postal: dto.code_postal, + ville: dto.ville, + token_creation_mdp: tokenCreationMdp, + token_creation_mdp_expire_le: dateExpiration, + photo_url: urlPhoto ?? undefined, + consentement_photo: dto.consentement_photo, + date_consentement_photo: dateConsentementPhoto, + date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined, + }); + const userEnregistre = await manager.save(Users, user); + + const amRepo = manager.getRepository(AssistanteMaternelle); + const am = amRepo.create({ + user_id: userEnregistre.id, + approval_number: dto.numero_agrement, + nir: dto.nir, + max_children: dto.capacite_accueil, + biography: dto.biographie, + residence_city: dto.ville ?? undefined, + agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined, + available: true, + }); + await amRepo.save(am); + + return { user: userEnregistre }; + }); + + return { + message: + 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.', + user_id: resultat.user.id, + statut: StatutUtilisateurType.EN_ATTENTE, + }; + } + /** * Sauvegarde une photo depuis base64 vers le système de fichiers */ diff --git a/backend/src/routes/auth/dto/register-am-complet.dto.ts b/backend/src/routes/auth/dto/register-am-complet.dto.ts new file mode 100644 index 0000000..72728ca --- /dev/null +++ b/backend/src/routes/auth/dto/register-am-complet.dto.ts @@ -0,0 +1,156 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + IsBoolean, + IsInt, + Min, + Max, + MinLength, + MaxLength, + Matches, + IsDateString, +} from 'class-validator'; + +export class RegisterAMCompletDto { + // ============================================ + // ÉTAPE 1 : IDENTITÉ (Obligatoire) + // ============================================ + + @ApiProperty({ example: 'marie.dupont@ptits-pas.fr' }) + @IsEmail({}, { message: 'Email invalide' }) + @IsNotEmpty({ message: "L'email est requis" }) + email: string; + + @ApiProperty({ example: 'Marie' }) + @IsString() + @IsNotEmpty({ message: 'Le prénom est requis' }) + @MinLength(2, { message: 'Le prénom doit contenir au moins 2 caractères' }) + @MaxLength(100) + prenom: string; + + @ApiProperty({ example: 'DUPONT' }) + @IsString() + @IsNotEmpty({ message: 'Le nom est requis' }) + @MinLength(2, { message: 'Le nom doit contenir au moins 2 caractères' }) + @MaxLength(100) + nom: string; + + @ApiProperty({ example: '0689567890' }) + @IsString() + @IsNotEmpty({ message: 'Le téléphone est requis' }) + @Matches(/^(\+33|0)[1-9](\d{2}){4}$/, { + message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)', + }) + telephone: string; + + @ApiProperty({ example: '5 Avenue du Général de Gaulle', required: false }) + @IsOptional() + @IsString() + adresse?: string; + + @ApiProperty({ example: '95870', required: false }) + @IsOptional() + @IsString() + @MaxLength(10) + code_postal?: string; + + @ApiProperty({ example: 'Bezons', required: false }) + @IsOptional() + @IsString() + @MaxLength(150) + ville?: string; + + // ============================================ + // ÉTAPE 2 : PHOTO + INFOS PRO + // ============================================ + + @ApiProperty({ + example: 'data:image/jpeg;base64,/9j/4AAQ...', + required: false, + description: 'Photo de profil en base64', + }) + @IsOptional() + @IsString() + photo_base64?: string; + + @ApiProperty({ example: 'photo_profil.jpg', required: false }) + @IsOptional() + @IsString() + photo_filename?: string; + + @ApiProperty({ example: true, description: 'Consentement utilisation photo' }) + @IsBoolean() + @IsNotEmpty({ message: 'Le consentement photo est requis' }) + consentement_photo: boolean; + + @ApiProperty({ example: '2024-01-15', required: false, description: 'Date de naissance' }) + @IsOptional() + @IsDateString() + date_naissance?: string; + + @ApiProperty({ example: 'Paris', required: false, description: 'Ville de naissance' }) + @IsOptional() + @IsString() + @MaxLength(100) + lieu_naissance_ville?: string; + + @ApiProperty({ example: 'France', required: false, description: 'Pays de naissance' }) + @IsOptional() + @IsString() + @MaxLength(100) + lieu_naissance_pays?: string; + + @ApiProperty({ example: '123456789012345', description: 'NIR 15 chiffres' }) + @IsString() + @IsNotEmpty({ message: 'Le NIR est requis' }) + @Matches(/^\d{15}$/, { message: 'Le NIR doit contenir exactement 15 chiffres' }) + nir: string; + + @ApiProperty({ example: 'AGR-2024-12345', description: "Numéro d'agrément" }) + @IsString() + @IsNotEmpty({ message: "Le numéro d'agrément est requis" }) + @MaxLength(50) + numero_agrement: string; + + @ApiProperty({ example: '2024-06-01', required: false, description: "Date d'obtention de l'agrément" }) + @IsOptional() + @IsDateString() + date_agrement?: string; + + @ApiProperty({ example: 4, description: 'Capacité d\'accueil (nombre d\'enfants)', minimum: 1, maximum: 10 }) + @IsInt() + @Min(1, { message: 'La capacité doit être au moins 1' }) + @Max(10, { message: 'La capacité ne peut pas dépasser 10' }) + capacite_accueil: number; + + // ============================================ + // ÉTAPE 3 : PRÉSENTATION (Optionnel) + // ============================================ + + @ApiProperty({ + example: 'Assistante maternelle expérimentée, accueil bienveillant...', + required: false, + description: 'Présentation / biographie (max 2000 caractères)', + }) + @IsOptional() + @IsString() + @MaxLength(2000, { message: 'La présentation ne peut pas dépasser 2000 caractères' }) + biographie?: string; + + // ============================================ + // ÉTAPE 4 : ACCEPTATION CGU (Obligatoire) + // ============================================ + + @ApiProperty({ example: true, description: "Acceptation des CGU" }) + @IsBoolean() + @IsNotEmpty({ message: "L'acceptation des CGU est requise" }) + acceptation_cgu: boolean; + + @ApiProperty({ example: true, description: 'Acceptation de la Politique de confidentialité' }) + @IsBoolean() + @IsNotEmpty({ message: "L'acceptation de la politique de confidentialité est requise" }) + acceptation_privacy: boolean; +} diff --git a/backend/src/routes/auth/dto/register-parent.dto.ts b/backend/src/routes/auth/dto/register-parent.dto.ts deleted file mode 100644 index a022724..0000000 --- a/backend/src/routes/auth/dto/register-parent.dto.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { - IsEmail, - IsNotEmpty, - IsOptional, - IsString, - IsDateString, - IsEnum, - MinLength, - MaxLength, - Matches, -} from 'class-validator'; -import { SituationFamilialeType } from 'src/entities/users.entity'; - -export class RegisterParentDto { - // === Informations obligatoires === - @ApiProperty({ example: 'claire.martin@ptits-pas.fr' }) - @IsEmail({}, { message: 'Email invalide' }) - @IsNotEmpty({ message: 'L\'email est requis' }) - email: string; - - @ApiProperty({ example: 'Claire' }) - @IsString() - @IsNotEmpty({ message: 'Le prénom est requis' }) - @MinLength(2, { message: 'Le prénom doit contenir au moins 2 caractères' }) - @MaxLength(100, { message: 'Le prénom ne peut pas dépasser 100 caractères' }) - prenom: string; - - @ApiProperty({ example: 'MARTIN' }) - @IsString() - @IsNotEmpty({ message: 'Le nom est requis' }) - @MinLength(2, { message: 'Le nom doit contenir au moins 2 caractères' }) - @MaxLength(100, { message: 'Le nom ne peut pas dépasser 100 caractères' }) - nom: string; - - @ApiProperty({ example: '0689567890' }) - @IsString() - @IsNotEmpty({ message: 'Le téléphone est requis' }) - @Matches(/^(\+33|0)[1-9](\d{2}){4}$/, { - message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)', - }) - telephone: string; - - // === Informations optionnelles === - @ApiProperty({ example: '5 Avenue du Général de Gaulle', required: false }) - @IsOptional() - @IsString() - adresse?: string; - - @ApiProperty({ example: '95870', required: false }) - @IsOptional() - @IsString() - @MaxLength(10) - code_postal?: string; - - @ApiProperty({ example: 'Bezons', required: false }) - @IsOptional() - @IsString() - @MaxLength(150) - ville?: string; - - // === Informations co-parent (optionnel) === - @ApiProperty({ example: 'thomas.martin@ptits-pas.fr', required: false }) - @IsOptional() - @IsEmail({}, { message: 'Email du co-parent invalide' }) - co_parent_email?: string; - - @ApiProperty({ example: 'Thomas', required: false }) - @IsOptional() - @IsString() - co_parent_prenom?: string; - - @ApiProperty({ example: 'MARTIN', required: false }) - @IsOptional() - @IsString() - co_parent_nom?: string; - - @ApiProperty({ example: '0612345678', required: false }) - @IsOptional() - @IsString() - @Matches(/^(\+33|0)[1-9](\d{2}){4}$/, { - message: 'Le numéro de téléphone du co-parent doit être valide', - }) - co_parent_telephone?: string; - - @ApiProperty({ example: 'true', description: 'Le co-parent habite à la même adresse', required: false }) - @IsOptional() - co_parent_meme_adresse?: boolean; - - @ApiProperty({ required: false }) - @IsOptional() - @IsString() - co_parent_adresse?: string; - - @ApiProperty({ required: false }) - @IsOptional() - @IsString() - co_parent_code_postal?: string; - - @ApiProperty({ required: false }) - @IsOptional() - @IsString() - co_parent_ville?: string; -} - diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 831e911..774b1d2 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -5,6 +5,8 @@ class ApiConfig { // Auth endpoints static const String login = '/auth/login'; static const String register = '/auth/register'; + static const String registerParent = '/auth/register/parent'; + static const String registerAM = '/auth/register/am'; static const String refreshToken = '/auth/refresh'; static const String authMe = '/auth/me'; static const String changePasswordRequired = '/auth/change-password-required'; From 31bd8c3175bb1ceffa9a380385997e66573121de Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 16 Feb 2026 16:18:06 +0100 Subject: [PATCH 11/11] =?UTF-8?q?fix(#90):=20BDD=20assistantes=5Fmaternell?= =?UTF-8?q?es=20align=C3=A9e=20entit=C3=A9=20+=20script=20test=20curl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BDD.sql: ville_residence, annee_experience, specialite, date_agrement nullable - scripts/test-register-am.sh pour tester POST /auth/register/am Co-authored-by: Cursor --- backend/scripts/test-register-am.sh | 27 +++++++++++++++++++++++++++ database/BDD.sql | 9 ++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) create mode 100755 backend/scripts/test-register-am.sh diff --git a/backend/scripts/test-register-am.sh b/backend/scripts/test-register-am.sh new file mode 100755 index 0000000..0ae987e --- /dev/null +++ b/backend/scripts/test-register-am.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Test POST /auth/register/am (ticket #90) +# Usage: ./scripts/test-register-am.sh [BASE_URL] +# Exemple: ./scripts/test-register-am.sh https://app.ptits-pas.fr/api/v1 +# ./scripts/test-register-am.sh http://localhost:3000/api/v1 + +BASE_URL="${1:-http://localhost:3000/api/v1}" +echo "Testing POST $BASE_URL/auth/register/am" +echo "---" + +curl -s -w "\n\nHTTP %{http_code}\n" -X POST "$BASE_URL/auth/register/am" \ + -H "Content-Type: application/json" \ + -d '{ + "email": "marie.dupont.test@ptits-pas.fr", + "prenom": "Marie", + "nom": "DUPONT", + "telephone": "0612345678", + "adresse": "1 rue Test", + "code_postal": "75001", + "ville": "Paris", + "consentement_photo": true, + "nir": "123456789012345", + "numero_agrement": "AGR-2024-001", + "capacite_accueil": 4, + "acceptation_cgu": true, + "acceptation_privacy": true + }' diff --git a/database/BDD.sql b/database/BDD.sql index 1e7bc0b..991ce3a 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -80,12 +80,15 @@ CREATE INDEX idx_utilisateurs_token_creation_mdp CREATE TABLE assistantes_maternelles ( id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE, numero_agrement VARCHAR(50), - date_agrement DATE NOT NULL, -- Obligatoire selon CDC v1.3 nir_chiffre CHAR(15), nb_max_enfants INT, - place_disponible INT, biographie TEXT, - disponible BOOLEAN DEFAULT true + disponible BOOLEAN DEFAULT true, + ville_residence VARCHAR(100), + date_agrement DATE, + annee_experience SMALLINT, + specialite VARCHAR(100), + place_disponible INT ); -- ==========================================================