merge: resolution conflits develop -> master (ticket #14)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
b16dd4b55c
18
.gitattributes
vendored
Normal file
18
.gitattributes
vendored
Normal 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
|
||||
@ -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
|
||||
|
||||
27
backend/scripts/test-register-am.sh
Executable file
27
backend/scripts/test-register-am.sh
Executable 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
|
||||
}'
|
||||
69
backend/src/common/interceptors/log-request.interceptor.ts
Normal file
69
backend/src/common/interceptors/log-request.interceptor.ts
Normal 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)
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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<void> {
|
||||
await this.set('setup_completed', 'true', userId);
|
||||
async markSetupCompleted(userId: string | null): Promise<void> {
|
||||
await this.set('setup_completed', 'true', userId ?? undefined);
|
||||
this.logger.log('✅ Configuration initiale marquée comme terminée');
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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<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
|
||||
* 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
|
||||
*/
|
||||
|
||||
156
backend/src/routes/auth/dto/register-am-complet.dto.ts
Normal file
156
backend/src/routes/auth/dto/register-am-complet.dto.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
);
|
||||
|
||||
-- ==========================================================
|
||||
|
||||
37
docs/14_NOTE-BACKEND-CONFIG-SETUP.md
Normal file
37
docs/14_NOTE-BACKEND-CONFIG-SETUP.md
Normal 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"` 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<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.
|
||||
@ -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';
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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<AdminDashboardScreen> {
|
||||
/// 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<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) {
|
||||
setState(() {
|
||||
mainTabIndex = index;
|
||||
@ -34,6 +55,11 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
||||
|
||||
@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<AdminDashboardScreen> {
|
||||
child: DashboardAppBarAdmin(
|
||||
selectedIndex: mainTabIndex,
|
||||
onTabChange: onMainTabChange,
|
||||
setupCompleted: _setupCompleted!,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -67,7 +94,7 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
||||
|
||||
Widget _getBody() {
|
||||
if (mainTabIndex == 1) {
|
||||
return const ParametresPanel();
|
||||
return ParametresPanel(redirectToLoginAfterSave: !_setupCompleted!);
|
||||
}
|
||||
switch (subIndex) {
|
||||
case 0:
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -55,6 +55,12 @@ class ConfigurationService {
|
||||
: 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
|
||||
static Future<bool> 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<String, dynamic>?;
|
||||
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement configuration');
|
||||
}
|
||||
final data = jsonDecode(response.body);
|
||||
final list = data['data'] as List<dynamic>? ?? [];
|
||||
@ -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<String, dynamic>?;
|
||||
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement configuration');
|
||||
}
|
||||
final data = jsonDecode(response.body);
|
||||
final map = data['data'] as Map<String, dynamic>? ?? {};
|
||||
@ -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<String, dynamic>?;
|
||||
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<String, dynamic>?;
|
||||
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<String, dynamic>?;
|
||||
final msg = err != null ? (_toStr(err['error']) ?? _toStr(err['message'])) : null;
|
||||
throw Exception(msg ?? 'Erreur finalisation configuration');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<int> 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,10 +78,12 @@ 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),
|
||||
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(
|
||||
@ -94,6 +100,7 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<int> onSubTabChange;
|
||||
|
||||
@ -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<ParametresPanel> createState() => _ParametresPanelState();
|
||||
@ -104,7 +108,14 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
||||
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 {
|
||||
final redirectAfter = widget.redirectToLoginAfterSave;
|
||||
setState(() {
|
||||
_message = null;
|
||||
_isSaving = true;
|
||||
@ -112,10 +123,16 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
||||
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<ParametresPanel> {
|
||||
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;
|
||||
|
||||
19
scripts/README-create-issue.md
Normal file
19
scripts/README-create-issue.md
Normal file
@ -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`).
|
||||
51
scripts/create-gitea-issue.sh
Normal file
51
scripts/create-gitea-issue.sh
Normal 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
|
||||
58
scripts/gitea-close-issue-with-comment.sh
Normal file
58
scripts/gitea-close-issue-with-comment.sh
Normal 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
14
scripts/issue-84-body.txt
Normal 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
|
||||
1
scripts/issue-84-payload.json
Normal file
1
scripts/issue-84-payload.json
Normal 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"}
|
||||
Loading…
x
Reference in New Issue
Block a user