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
|
# 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
|
||||||
|
|||||||
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 { 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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
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 (
|
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
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
|
|||||||
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 '/')
|
// 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: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:
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,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;
|
final bool isActive = index == selectedIndex;
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () => onTabChange(index),
|
onTap: enabled ? () => onTabChange(index) : null,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: enabled ? 1.0 : 0.5,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
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'),
|
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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
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