diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1540ca9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,18 @@ +# Fins de ligne : toujours LF dans le dépôt (évite les conflits Linux/Windows) +* text=auto eol=lf + +# Fichiers binaires : pas de conversion +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.pdf binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary + +# Scripts shell : toujours LF +*.sh text eol=lf 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/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/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 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'); } 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/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 ); -- ========================================================== 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/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 be288bd..1868c9d 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,32 @@ class AdminDashboardScreen extends StatefulWidget { } class _AdminDashboardScreenState extends State { - /// 0 = Gestion des utilisateurs, 1 = Paramètres + bool? _setupCompleted; 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 (e) { + if (mounted) setState(() { + _setupCompleted = false; + mainTabIndex = 1; + }); + } + } + void onMainTabChange(int index) { setState(() { mainTabIndex = index; @@ -34,6 +55,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 +72,7 @@ class _AdminDashboardScreenState extends State { child: DashboardAppBarAdmin( selectedIndex: mainTabIndex, onTabChange: onMainTabChange, + setupCompleted: _setupCompleted!, ), ), ), @@ -67,7 +94,7 @@ class _AdminDashboardScreenState extends State { Widget _getBody() { if (mainTabIndex == 1) { - return const ParametresPanel(); + return ParametresPanel(redirectToLoginAfterSave: !_setupCompleted!); } switch (subIndex) { case 0: 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'; diff --git a/frontend/lib/services/configuration_service.dart b/frontend/lib/services/configuration_service.dart index 8f1c905..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,10 @@ class ConfigurationService { 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'); + if (response.statusCode != 200 && response.statusCode != 201) { + final err = jsonDecode(response.body) as Map?; + final msg = err != null ? (_toStr(err['error']) ?? _toStr(err['message'])) : null; + throw Exception(msg ?? 'Erreur lors de la sauvegarde'); } } @@ -118,11 +127,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 || response.statusCode == 201) && (data?['success'] == true)) { + return _toStr(data?['message']) ?? 'Test SMTP réussi.'; } - throw Exception(data['error'] ?? data['message'] ?? 'Échec du test SMTP'); + final msg = data != null ? (_toStr(data['error']) ?? _toStr(data['message'])) : null; + throw Exception(msg ?? 'Échec du test SMTP'); } /// POST /api/v1/configuration/setup/complete (après première config) @@ -131,9 +141,10 @@ class ConfigurationService { 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'); + if (response.statusCode != 200 && response.statusCode != 201) { + final err = jsonDecode(response.body) as Map?; + 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 d19030a..12fbe8b 100644 --- a/frontend/lib/widgets/admin/dashboard_admin.dart +++ b/frontend/lib/widgets/admin/dashboard_admin.dart @@ -1,14 +1,18 @@ 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; + final bool setupCompleted; const DashboardAppBarAdmin({ Key? key, required this.selectedIndex, required this.onTabChange, + this.setupCompleted = true, }) : super(key: key); @override @@ -32,9 +36,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 +78,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, + ), ), ), ), @@ -109,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'), ), @@ -121,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 c50ecdd..22e7b7c 100644 --- a/frontend/lib/widgets/admin/parametres_panel.dart +++ b/frontend/lib/widgets/admin/parametres_panel.dart @@ -1,9 +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 { - const ParametresPanel({super.key}); + /// 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.redirectToLoginAfterSave = false}); @override State createState() => _ParametresPanelState(); @@ -104,7 +108,14 @@ class _ParametresPanelState extends State { return payload; } + /// Sauvegarde en base sans completeSetup (utilisé avant test SMTP). + Future _saveBulkOnly() async { + await ConfigurationService.updateBulk(_buildPayload()); + } + + /// 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; @@ -112,10 +123,16 @@ class _ParametresPanelState extends State { try { await ConfigurationService.updateBulk(_buildPayload()); if (!mounted) return; + await ConfigurationService.completeSetup(); + if (!mounted) return; setState(() { _isSaving = false; _message = 'Configuration enregistrée.'; }); + if (!mounted) return; + if (redirectAfter) { + GoRouter.of(context).go('/login'); + } } catch (e) { if (mounted) { setState(() { @@ -160,7 +177,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; diff --git a/scripts/README-create-issue.md b/scripts/README-create-issue.md new file mode 100644 index 0000000..51fd65d --- /dev/null +++ b/scripts/README-create-issue.md @@ -0,0 +1,19 @@ +# Créer l’issue #84 (correctifs modale MDP) via l’API Gitea + +1. Définir un token valide : + `export GITEA_TOKEN="votre_token"` + ou créer `.gitea-token` à la racine du projet avec le token seul. + +2. Créer l’issue : + ```bash + cd /chemin/vers/PetitsPas + curl -s -X POST \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d @scripts/issue-84-payload.json \ + "https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues" + ``` + +3. En cas de succès (HTTP 201), la réponse JSON contient le numéro de l’issue créée. + +Payload utilisé : `scripts/issue-84-payload.json` (titre + corps depuis `scripts/issue-84-body.txt`). diff --git a/scripts/create-gitea-issue.sh b/scripts/create-gitea-issue.sh new file mode 100644 index 0000000..3315612 --- /dev/null +++ b/scripts/create-gitea-issue.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Crée une issue Gitea via l'API. +# Usage: GITEA_TOKEN=xxx ./scripts/create-gitea-issue.sh +# Ou: mettre le token dans .gitea-token à la racine du projet. + +set -e +BASE_URL="${GITEA_URL:-https://git.ptits-pas.fr/api/v1}" +REPO="jmartin/petitspas" + +if [ -z "$GITEA_TOKEN" ]; then + if [ -f .gitea-token ]; then + GITEA_TOKEN=$(cat .gitea-token) + fi +fi + +if [ -z "$GITEA_TOKEN" ]; then + echo "Définir GITEA_TOKEN ou créer .gitea-token avec votre token Gitea." + exit 1 +fi + +TITLE="$1" +BODY="$2" +if [ -z "$TITLE" ]; then + echo "Usage: $0 \"Titre de l'issue\" \"Corps (optionnel)\"" + exit 1 +fi + +# Build JSON (escape body for JSON) +BODY_ESC=$(echo "$BODY" | jq -Rs . 2>/dev/null || echo "null") +if [ "$BODY_ESC" = "null" ] || [ -z "$BODY" ]; then + PAYLOAD=$(jq -n --arg t "$TITLE" '{title: $t}') +else + PAYLOAD=$(jq -n --arg t "$TITLE" --arg b "$BODY" '{title: $t, body: $b}') +fi + +RESP=$(curl -s -w "\n%{http_code}" -X POST \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + "$BASE_URL/repos/$REPO/issues") +HTTP_CODE=$(echo "$RESP" | tail -1) +BODY_RESP=$(echo "$RESP" | sed '$d') + +if [ "$HTTP_CODE" = "201" ]; then + ISSUE_NUM=$(echo "$BODY_RESP" | jq -r .number) + echo "Issue #$ISSUE_NUM créée." + echo "$BODY_RESP" | jq . +else + echo "Erreur HTTP $HTTP_CODE: $BODY_RESP" + exit 1 +fi diff --git a/scripts/gitea-close-issue-with-comment.sh b/scripts/gitea-close-issue-with-comment.sh new file mode 100644 index 0000000..ad545b8 --- /dev/null +++ b/scripts/gitea-close-issue-with-comment.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Poste un commentaire sur une issue Gitea puis la ferme. +# Usage: GITEA_TOKEN=xxx ./scripts/gitea-close-issue-with-comment.sh "Commentaire" +# Ou: mettre le token dans .gitea-token à la racine du projet. +# Exemple: ./scripts/gitea-close-issue-with-comment.sh 15 "Livré : panneau Paramètres opérationnel." + +set -e +ISSUE="${1:?Usage: $0 \"Commentaire\"}" +COMMENT="${2:?Usage: $0 \"Commentaire\"}" +BASE_URL="${GITEA_URL:-https://git.ptits-pas.fr/api/v1}" +REPO="jmartin/petitspas" + +if [ -z "$GITEA_TOKEN" ]; then + if [ -f .gitea-token ]; then + GITEA_TOKEN=$(cat .gitea-token) + fi +fi + +if [ -z "$GITEA_TOKEN" ]; then + echo "Définir GITEA_TOKEN ou créer .gitea-token avec votre token Gitea." + exit 1 +fi + +# 1) Poster le commentaire +echo "Ajout du commentaire sur l'issue #$ISSUE..." +# Échapper pour JSON (guillemets et backslash) +COMMENT_ESC=$(printf '%s' "$COMMENT" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\r//g') +PAYLOAD="{\"body\":\"$COMMENT_ESC\"}" +RESP=$(curl -s -w "\n%{http_code}" -X POST \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + "$BASE_URL/repos/$REPO/issues/$ISSUE/comments") +HTTP_CODE=$(echo "$RESP" | tail -1) +BODY=$(echo "$RESP" | sed '$d') + +if [ "$HTTP_CODE" != "201" ]; then + echo "Erreur HTTP $HTTP_CODE lors du commentaire: $BODY" + exit 1 +fi +echo "Commentaire ajouté." + +# 2) Fermer l'issue +echo "Fermeture de l'issue #$ISSUE..." +RESP2=$(curl -s -w "\n%{http_code}" -X PATCH \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"state":"closed"}' \ + "$BASE_URL/repos/$REPO/issues/$ISSUE") +HTTP_CODE2=$(echo "$RESP2" | tail -1) +BODY2=$(echo "$RESP2" | sed '$d') + +if [ "$HTTP_CODE2" = "200" ] || [ "$HTTP_CODE2" = "201" ]; then + echo "Issue #$ISSUE fermée." +else + echo "Erreur HTTP $HTTP_CODE2: $BODY2" + exit 1 +fi diff --git a/scripts/issue-84-body.txt b/scripts/issue-84-body.txt new file mode 100644 index 0000000..770e6f3 --- /dev/null +++ b/scripts/issue-84-body.txt @@ -0,0 +1,14 @@ +Correctifs et améliorations de la modale de changement de mot de passe obligatoire affichée à la première connexion admin. + +**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 diff --git a/scripts/issue-84-payload.json b/scripts/issue-84-payload.json new file mode 100644 index 0000000..731ad3d --- /dev/null +++ b/scripts/issue-84-payload.json @@ -0,0 +1 @@ +{"title": "[Frontend] Bug – Correctifs modale Changement MDP (première connexion admin)", "body": "Correctifs et améliorations de la modale de changement de mot de passe obligatoire affichée à la première connexion admin.\n\n**Périmètre :**\n- Ajustements visuels / UX de la modale (ChangePasswordDialog)\n- Cohérence charte graphique, espacements, lisibilité\n- Comportement (validation, messages d'erreur, fermeture)\n- Lien de test en debug sur l'écran login (« Test modale MDP ») pour faciliter les réglages\n\n**Tâches :**\n- [ ] Revoir le design de la modale (relief, bordures, couleurs)\n- [ ] Vérifier les champs (MDP actuel, nouveau, confirmation) et validations\n- [ ] Ajuster les textes et messages d'erreur\n- [ ] Tester sur mobile et desktop\n- [ ] Retirer ou conditionner le lien « Test modale MDP » en production si besoin\n"} \ No newline at end of file