merge: resolution conflits develop -> master (ticket #14)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MARTIN Julien 2026-02-16 16:22:04 +01:00
commit b16dd4b55c
25 changed files with 671 additions and 292 deletions

18
.gitattributes vendored Normal file
View File

@ -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

View File

@ -21,3 +21,6 @@ JWT_EXPIRATION_TIME=7d
# Environnement # Environnement
NODE_ENV=development NODE_ENV=development
# Log de chaque appel API (mode debug) — mettre à true pour tracer les requêtes front
# LOG_API_REQUESTS=true

View File

@ -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
}'

View File

@ -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<string, unknown> = {};
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<unknown> {
if (!this.enabled) return next.handle();
const http = context.switchToHttp();
const req = http.getRequest<Request>();
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)
},
}),
);
}
}

View File

@ -1,17 +1,18 @@
import { NestFactory, Reflector } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module'; import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module';
import { DocumentBuilder } from '@nestjs/swagger'; 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 { ValidationPipe } from '@nestjs/common';
import { LogRequestInterceptor } from './common/interceptors/log-request.interceptor';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule, const app = await NestFactory.create(AppModule,
{ logger: ['error', 'warn', 'log', 'debug', 'verbose'] }); { 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 // Configuration CORS pour autoriser les requêtes depuis localhost (dev) et production
app.enableCors({ app.enableCors({
origin: true, // Autorise toutes les origines (dev) - à restreindre en prod origin: true, // Autorise toutes les origines (dev) - à restreindre en prod

View File

@ -53,8 +53,7 @@ export class ConfigController {
// @Roles('super_admin') // @Roles('super_admin')
async completeSetup(@Request() req: any) { async completeSetup(@Request() req: any) {
try { try {
// TODO: Récupérer l'ID utilisateur depuis le JWT const userId = req.user?.id ?? null;
const userId = req.user?.id || 'system';
await this.configService.markSetupCompleted(userId); await this.configService.markSetupCompleted(userId);

View File

@ -259,10 +259,10 @@ export class AppConfigService implements OnModuleInit {
/** /**
* Marquer la configuration initiale comme terminée * 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<void> { async markSetupCompleted(userId: string | null): Promise<void> {
await this.set('setup_completed', 'true', userId); await this.set('setup_completed', 'true', userId ?? undefined);
this.logger.log('✅ Configuration initiale marquée comme terminée'); this.logger.log('✅ Configuration initiale marquée comme terminée');
} }

View File

@ -3,8 +3,8 @@ import { LoginDto } from './dto/login.dto';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { Public } from 'src/common/decorators/public.decorator'; import { Public } from 'src/common/decorators/public.decorator';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { RegisterParentDto } from './dto/register-parent.dto';
import { RegisterParentCompletDto } from './dto/register-parent-complet.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 { ChangePasswordRequiredDto } from './dto/change-password.dto';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AuthGuard } from 'src/common/guards/auth.guard'; import { AuthGuard } from 'src/common/guards/auth.guard';
@ -53,12 +53,16 @@ export class AuthController {
} }
@Public() @Public()
@Post('register/parent/legacy') @Post('register/am')
@ApiOperation({ summary: '[OBSOLÈTE] Inscription Parent (étape 1/6 uniquement)' }) @ApiOperation({
@ApiResponse({ status: 201, description: 'Inscription réussie' }) 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é' }) @ApiResponse({ status: 409, description: 'Email déjà utilisé' })
async registerParentLegacy(@Body() dto: RegisterParentDto) { async inscrireAMComplet(@Body() dto: RegisterAMCompletDto) {
return this.authService.registerParent(dto); return this.authService.inscrireAMComplet(dto);
} }
@Public() @Public()

View File

@ -8,11 +8,12 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { Users } from 'src/entities/users.entity'; import { Users } from 'src/entities/users.entity';
import { Parents } from 'src/entities/parents.entity'; import { Parents } from 'src/entities/parents.entity';
import { Children } from 'src/entities/children.entity'; import { Children } from 'src/entities/children.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { AppConfigModule } from 'src/modules/config'; import { AppConfigModule } from 'src/modules/config';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Users, Parents, Children]), TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]),
forwardRef(() => UserModule), forwardRef(() => UserModule),
AppConfigModule, AppConfigModule,
JwtModule.registerAsync({ JwtModule.registerAsync({

View File

@ -13,13 +13,14 @@ import * as crypto from 'crypto';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { RegisterParentDto } from './dto/register-parent.dto';
import { RegisterParentCompletDto } from './dto/register-parent-complet.dto'; import { RegisterParentCompletDto } from './dto/register-parent-complet.dto';
import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity'; import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity';
import { Parents } from 'src/entities/parents.entity'; import { Parents } from 'src/entities/parents.entity';
import { Children, StatutEnfantType } from 'src/entities/children.entity'; import { Children, StatutEnfantType } from 'src/entities/children.entity';
import { ParentsChildren } from 'src/entities/parents_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 { LoginDto } from './dto/login.dto';
import { AppConfigService } from 'src/modules/config/config.service'; 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 * @deprecated
*/ */
async register(registerDto: RegisterDto) { 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<number>(
'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 * Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction
* Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU * 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<number>(
'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 * Sauvegarde une photo depuis base64 vers le système de fichiers
*/ */

View File

@ -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;
}

View File

@ -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;
}

View File

@ -80,12 +80,15 @@ CREATE INDEX idx_utilisateurs_token_creation_mdp
CREATE TABLE assistantes_maternelles ( CREATE TABLE assistantes_maternelles (
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE, id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
numero_agrement VARCHAR(50), numero_agrement VARCHAR(50),
date_agrement DATE NOT NULL, -- Obligatoire selon CDC v1.3
nir_chiffre CHAR(15), nir_chiffre CHAR(15),
nb_max_enfants INT, nb_max_enfants INT,
place_disponible INT,
biographie TEXT, 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
); );
-- ========================================================== -- ==========================================================

View File

@ -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"` nest pas un UUID valide → erreur PostgreSQL.
## Modifications à apporter au backend
**Option A Accepter labsence dutilisateur (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<void>`
- 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.

View File

@ -6,7 +6,7 @@ class Env {
); );
// Construit une URL vers l'API v1 à partir d'un chemin (commençant par '/') // 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';
} }

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; 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/assistante_maternelle_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_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/parent_managmant_widget.dart';
@ -14,12 +15,32 @@ class AdminDashboardScreen extends StatefulWidget {
} }
class _AdminDashboardScreenState extends State<AdminDashboardScreen> { class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
/// 0 = Gestion des utilisateurs, 1 = Paramètres bool? _setupCompleted;
int mainTabIndex = 0; int mainTabIndex = 0;
/// Sous-onglet quand mainTabIndex == 0 : 0=Gestionnaires, 1=Parents, 2=AM, 3=Administrateurs
int subIndex = 0; int subIndex = 0;
@override
void initState() {
super.initState();
_loadSetupStatus();
}
Future<void> _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) { void onMainTabChange(int index) {
setState(() { setState(() {
mainTabIndex = index; mainTabIndex = index;
@ -34,6 +55,11 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_setupCompleted == null) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold( return Scaffold(
appBar: PreferredSize( appBar: PreferredSize(
preferredSize: const Size.fromHeight(60.0), preferredSize: const Size.fromHeight(60.0),
@ -46,6 +72,7 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
child: DashboardAppBarAdmin( child: DashboardAppBarAdmin(
selectedIndex: mainTabIndex, selectedIndex: mainTabIndex,
onTabChange: onMainTabChange, onTabChange: onMainTabChange,
setupCompleted: _setupCompleted!,
), ),
), ),
), ),
@ -67,7 +94,7 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
Widget _getBody() { Widget _getBody() {
if (mainTabIndex == 1) { if (mainTabIndex == 1) {
return const ParametresPanel(); return ParametresPanel(redirectToLoginAfterSave: !_setupCompleted!);
} }
switch (subIndex) { switch (subIndex) {
case 0: case 0:

View File

@ -5,6 +5,8 @@ class ApiConfig {
// Auth endpoints // Auth endpoints
static const String login = '/auth/login'; static const String login = '/auth/login';
static const String register = '/auth/register'; 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 refreshToken = '/auth/refresh';
static const String authMe = '/auth/me'; static const String authMe = '/auth/me';
static const String changePasswordRequired = '/auth/change-password-required'; static const String changePasswordRequired = '/auth/change-password-required';

View File

@ -55,6 +55,12 @@ class ConfigurationService {
: Map<String, String>.from(ApiConfig.headers); : Map<String, String>.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 /// GET /api/v1/configuration/setup/status
static Future<bool> getSetupStatus() async { static Future<bool> getSetupStatus() async {
final response = await http.get( final response = await http.get(
@ -63,7 +69,11 @@ class ConfigurationService {
); );
if (response.statusCode != 200) return true; if (response.statusCode != 200) return true;
final data = jsonDecode(response.body); 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) /// GET /api/v1/configuration (toutes les configs)
@ -73,9 +83,8 @@ class ConfigurationService {
headers: await _headers(), headers: await _headers(),
); );
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception( final err = jsonDecode(response.body) as Map<String, dynamic>?;
(jsonDecode(response.body) as Map)['message'] ?? 'Erreur chargement configuration', throw Exception(_toStr(err?['message']) ?? 'Erreur chargement configuration');
);
} }
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
final list = data['data'] as List<dynamic>? ?? []; final list = data['data'] as List<dynamic>? ?? [];
@ -89,9 +98,8 @@ class ConfigurationService {
headers: await _headers(), headers: await _headers(),
); );
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception( final err = jsonDecode(response.body) as Map<String, dynamic>?;
(jsonDecode(response.body) as Map)['message'] ?? 'Erreur chargement configuration', throw Exception(_toStr(err?['message']) ?? 'Erreur chargement configuration');
);
} }
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
final map = data['data'] as Map<String, dynamic>? ?? {}; final map = data['data'] as Map<String, dynamic>? ?? {};
@ -105,9 +113,10 @@ class ConfigurationService {
headers: await _headers(), headers: await _headers(),
body: jsonEncode(body), body: jsonEncode(body),
); );
if (response.statusCode != 200) { if (response.statusCode != 200 && response.statusCode != 201) {
final err = jsonDecode(response.body) as Map; final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(err['message'] ?? 'Erreur lors de la sauvegarde'); 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(), headers: await _headers(),
body: jsonEncode({'testEmail': testEmail}), body: jsonEncode({'testEmail': testEmail}),
); );
final data = jsonDecode(response.body) as Map; final data = jsonDecode(response.body) as Map<String, dynamic>?;
if (response.statusCode == 200 && data['success'] == true) { if ((response.statusCode == 200 || response.statusCode == 201) && (data?['success'] == true)) {
return data['message'] as String? ?? 'Test SMTP réussi.'; 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) /// POST /api/v1/configuration/setup/complete (après première config)
@ -131,9 +141,10 @@ class ConfigurationService {
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationSetupComplete}'), Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationSetupComplete}'),
headers: await _headers(), headers: await _headers(),
); );
if (response.statusCode != 200) { if (response.statusCode != 200 && response.statusCode != 201) {
final err = jsonDecode(response.body) as Map; final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(err['message'] ?? 'Erreur finalisation configuration'); final msg = err != null ? (_toStr(err['error']) ?? _toStr(err['message'])) : null;
throw Exception(msg ?? 'Erreur finalisation configuration');
} }
} }
} }

View File

@ -1,14 +1,18 @@
import 'package:flutter/material.dart'; 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 { class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidget {
final int selectedIndex; final int selectedIndex;
final ValueChanged<int> onTabChange; final ValueChanged<int> onTabChange;
final bool setupCompleted;
const DashboardAppBarAdmin({ const DashboardAppBarAdmin({
Key? key, Key? key,
required this.selectedIndex, required this.selectedIndex,
required this.onTabChange, required this.onTabChange,
this.setupCompleted = true,
}) : super(key: key); }) : super(key: key);
@override @override
@ -32,9 +36,9 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildNavItem(context, 'Gestion des utilisateurs', 0), _buildNavItem(context, 'Gestion des utilisateurs', 0, enabled: setupCompleted),
const SizedBox(width: 24), 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; final bool isActive = index == selectedIndex;
return InkWell( return InkWell(
onTap: () => onTabChange(index), onTap: enabled ? () => onTabChange(index) : null,
child: Container( child: Opacity(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), opacity: enabled ? 1.0 : 0.5,
decoration: BoxDecoration( child: Container(
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
borderRadius: BorderRadius.circular(20), decoration: BoxDecoration(
border: isActive ? null : Border.all(color: Colors.black26), color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
), borderRadius: BorderRadius.circular(20),
child: Text( border: isActive ? null : Border.all(color: Colors.black26),
title, ),
style: TextStyle( child: Text(
color: isActive ? Colors.white : Colors.black, title,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, style: TextStyle(
fontSize: 14, 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'), child: const Text('Annuler'),
), ),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () async {
Navigator.pop(context); 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'), 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 { class DashboardUserManagementSubBar extends StatelessWidget {
final int selectedSubIndex; final int selectedSubIndex;
final ValueChanged<int> onSubTabChange; final ValueChanged<int> onSubTabChange;

View File

@ -1,9 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/services/configuration_service.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 { 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 @override
State<ParametresPanel> createState() => _ParametresPanelState(); State<ParametresPanel> createState() => _ParametresPanelState();
@ -104,7 +108,14 @@ class _ParametresPanelState extends State<ParametresPanel> {
return payload; return payload;
} }
/// Sauvegarde en base sans completeSetup (utilisé avant test SMTP).
Future<void> _saveBulkOnly() async {
await ConfigurationService.updateBulk(_buildPayload());
}
/// Sauvegarde la config, marque le setup comme terminé. Si première config, redirige vers le login.
Future<void> _save() async { Future<void> _save() async {
final redirectAfter = widget.redirectToLoginAfterSave;
setState(() { setState(() {
_message = null; _message = null;
_isSaving = true; _isSaving = true;
@ -112,10 +123,16 @@ class _ParametresPanelState extends State<ParametresPanel> {
try { try {
await ConfigurationService.updateBulk(_buildPayload()); await ConfigurationService.updateBulk(_buildPayload());
if (!mounted) return; if (!mounted) return;
await ConfigurationService.completeSetup();
if (!mounted) return;
setState(() { setState(() {
_isSaving = false; _isSaving = false;
_message = 'Configuration enregistrée.'; _message = 'Configuration enregistrée.';
}); });
if (!mounted) return;
if (redirectAfter) {
GoRouter.of(context).go('/login');
}
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
setState(() { setState(() {
@ -160,7 +177,7 @@ class _ParametresPanelState extends State<ParametresPanel> {
if (email == null || !mounted) return; if (email == null || !mounted) return;
setState(() => _message = null); setState(() => _message = null);
try { try {
await _save(); await _saveBulkOnly();
if (!mounted) return; if (!mounted) return;
final msg = await ConfigurationService.testSmtp(email); final msg = await ConfigurationService.testSmtp(email);
if (!mounted) return; if (!mounted) return;

View File

@ -0,0 +1,19 @@
# Créer lissue #84 (correctifs modale MDP) via lAPI 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 lissue :
```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 lissue créée.
Payload utilisé : `scripts/issue-84-payload.json` (titre + corps depuis `scripts/issue-84-body.txt`).

View File

@ -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

View File

@ -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 <numéro> "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 <numéro_issue> \"Commentaire\"}"
COMMENT="${2:?Usage: $0 <numéro_issue> \"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

14
scripts/issue-84-body.txt Normal file
View File

@ -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

View File

@ -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"}