fix register

This commit is contained in:
David 2025-11-29 12:50:02 +01:00
parent b2f5d9968d
commit a34c850e67
10 changed files with 16677 additions and 92 deletions

View File

@ -29,7 +29,14 @@
"Bash(npx ts-node:*)",
"Bash(python3:*)",
"Read(//Users/david/.docker/**)",
"Bash(env)"
"Bash(env)",
"Bash(ssh david@xpeditis-cloud \"docker ps --filter name=xpeditis-backend --format ''{{.ID}} {{.Status}}''\")",
"Bash(git revert:*)",
"Bash(git log:*)",
"Bash(xargs -r docker rm:*)",
"Bash(npm run migration:run:*)",
"Bash(npm run dev:*)",
"Bash(npm run backend:dev:*)"
],
"deny": [],
"ask": []

115
CLAUDE.md
View File

@ -474,58 +474,20 @@ The platform supports CSV-based operations for bulk data management:
- Export to PDF documents using `pdfkit`
- File downloads using `file-saver` on frontend
## Common Development Tasks
## Admin User Management
### Adding a New Domain Entity
The platform includes a dedicated admin interface for user management:
1. Create entity in `src/domain/entities/entity-name.entity.ts`
2. Create value objects if needed in `src/domain/value-objects/`
3. Write unit tests: `entity-name.entity.spec.ts`
4. Add repository port in `src/domain/ports/out/entity-name.repository.ts`
5. Create ORM entity in `src/infrastructure/persistence/typeorm/entities/`
6. Implement repository in `src/infrastructure/persistence/typeorm/repositories/`
7. Create mapper in `src/infrastructure/persistence/typeorm/mappers/`
8. Generate migration: `npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/MigrationName`
**Admin Features** (Branch: `users_admin`):
- User CRUD operations (Create, Read, Update, Delete)
- Organization management
- Role assignment and permissions
- Argon2 password hash generation for new users
- Accessible at `/admin/users` (ADMIN role required)
### Adding a New API Endpoint
1. Create DTO in `src/application/dto/feature-name.dto.ts`
2. Add endpoint to controller in `src/application/controllers/`
3. Add Swagger decorators (`@ApiOperation`, `@ApiResponse`)
4. Create domain service in `src/domain/services/` if needed
5. Write unit tests for domain logic
6. Write integration tests for infrastructure
7. Update Postman collection in `postman/`
### Adding a New Carrier Integration
1. Create connector in `src/infrastructure/carriers/carrier-name/`
2. Implement `CarrierConnectorPort` interface
3. Add request/response mappers
4. Implement circuit breaker (5s timeout)
5. Add retry logic with exponential backoff
6. Write integration tests
7. Update carrier seed data
8. Add API credentials to `.env.example`
## Documentation
**Architecture & Planning**:
- [ARCHITECTURE.md](ARCHITECTURE.md) - System architecture (5,800 words)
- [DEPLOYMENT.md](DEPLOYMENT.md) - Deployment guide (4,500 words)
- [PRD.md](PRD.md) - Product requirements
- [TODO.md](TODO.md) - 30-week development roadmap
**Implementation Summaries**:
- [PHASE4_SUMMARY.md](PHASE4_SUMMARY.md) - Security, monitoring, testing
- [PHASE3_COMPLETE.md](PHASE3_COMPLETE.md) - Booking workflow, exports
- [PHASE2_COMPLETE.md](PHASE2_COMPLETE.md) - Authentication, RBAC
- [PHASE-1-WEEK5-COMPLETE.md](PHASE-1-WEEK5-COMPLETE.md) - Rate search, cache
**Testing**:
- [TEST_EXECUTION_GUIDE.md](TEST_EXECUTION_GUIDE.md) - How to run all tests
- [TEST_COVERAGE_REPORT.md](TEST_COVERAGE_REPORT.md) - Coverage metrics
- [GUIDE_TESTS_POSTMAN.md](GUIDE_TESTS_POSTMAN.md) - Postman API tests
**Password Hashing Utility**:
- Use `apps/backend/generate-hash.js` to generate Argon2 password hashes
- Example: `node apps/backend/generate-hash.js mypassword`
## Deployment
@ -542,23 +504,23 @@ docker build -t xpeditis-frontend:latest -f apps/frontend/Dockerfile .
docker-compose up -d
```
### Production Deployment (AWS)
### Production Deployment (Portainer)
See [DEPLOYMENT.md](DEPLOYMENT.md) for complete instructions:
- AWS RDS (PostgreSQL)
- AWS ElastiCache (Redis)
- AWS S3 (documents)
- AWS ECS/Fargate (containers)
- AWS ALB (load balancer)
- AWS CloudWatch (logs + metrics)
- Sentry (error tracking)
See [docker/PORTAINER_DEPLOYMENT_GUIDE.md](docker/PORTAINER_DEPLOYMENT_GUIDE.md) for complete instructions:
- Scaleway Container Registry (rg.fr-par.scw.cloud/weworkstudio)
- Docker Swarm stack deployment
- Traefik reverse proxy configuration
- Environment-specific configs (staging/production)
**CI/CD**: Automated via GitHub Actions
- Build and push Docker images
- Build and push Docker images to Scaleway Registry
- Deploy to staging/production via Portainer
- Run smoke tests post-deployment
See [docker/PORTAINER_DEPLOYMENT_GUIDE.md](docker/PORTAINER_DEPLOYMENT_GUIDE.md) for Portainer setup.
**Deployment Scripts**:
- `docker/build-images.sh` - Build and tag Docker images
- `deploy-to-portainer.sh` - Automated deployment script
- `docker/portainer-stack.yml` - Production stack configuration
## Performance Targets
@ -612,9 +574,32 @@ See [docker/PORTAINER_DEPLOYMENT_GUIDE.md](docker/PORTAINER_DEPLOYMENT_GUIDE.md)
- Document APIs with Swagger decorators
- Run migrations before deployment
## Support & Contribution
## Documentation
**Architecture & Planning**:
- [ARCHITECTURE.md](ARCHITECTURE.md) - System architecture (5,800 words)
- [DEPLOYMENT.md](DEPLOYMENT.md) - Deployment guide (4,500 words)
- [PRD.md](PRD.md) - Product requirements
- [TODO.md](TODO.md) - 30-week development roadmap
**Implementation Summaries**:
- [PHASE4_SUMMARY.md](PHASE4_SUMMARY.md) - Security, monitoring, testing
- [PHASE3_COMPLETE.md](PHASE3_COMPLETE.md) - Booking workflow, exports
- [PHASE2_COMPLETE.md](PHASE2_COMPLETE.md) - Authentication, RBAC
- [PHASE-1-WEEK5-COMPLETE.md](PHASE-1-WEEK5-COMPLETE.md) - Rate search, cache
**Testing**:
- [TEST_EXECUTION_GUIDE.md](TEST_EXECUTION_GUIDE.md) - How to run all tests
- [TEST_COVERAGE_REPORT.md](TEST_COVERAGE_REPORT.md) - Coverage metrics
- [GUIDE_TESTS_POSTMAN.md](GUIDE_TESTS_POSTMAN.md) - Postman API tests
**Deployment**:
- [docker/PORTAINER_DEPLOYMENT_GUIDE.md](docker/PORTAINER_DEPLOYMENT_GUIDE.md) - Portainer setup
- [docker/DOCKER_BUILD_GUIDE.md](docker/DOCKER_BUILD_GUIDE.md) - Docker build instructions
- [DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md) - Pre-deployment checklist
## Code Review Checklist
**Code Review Checklist**:
1. Hexagonal architecture principles followed
2. Domain layer has zero external dependencies
3. Unit tests written (90%+ coverage for domain)
@ -625,9 +610,3 @@ See [docker/PORTAINER_DEPLOYMENT_GUIDE.md](docker/PORTAINER_DEPLOYMENT_GUIDE.md)
8. TypeScript strict mode passes
9. Prettier formatting applied
10. ESLint passes with no warnings
**Getting Help**:
- Check existing documentation (ARCHITECTURE.md, DEPLOYMENT.md)
- Review Swagger API docs (http://localhost:4000/api/docs)
- Check GitHub Actions for CI failures
- Review Sentry for production errors

View File

@ -0,0 +1,14 @@
const argon2 = require('argon2');
async function generateHash() {
const hash = await argon2.hash('Password123!', {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3,
parallelism: 4,
});
console.log('Argon2id hash for "Password123!":');
console.log(hash);
}
generateHash().catch(console.error);

16250
apps/backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -9,8 +9,11 @@ import { AuthController } from '../controllers/auth.controller';
// Import domain and infrastructure dependencies
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity';
@Module({
imports: [
@ -29,8 +32,8 @@ import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities
}),
}),
// 👇 Add this to register TypeORM repository for UserOrmEntity
TypeOrmModule.forFeature([UserOrmEntity]),
// 👇 Add this to register TypeORM repositories
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity]),
],
controllers: [AuthController],
providers: [
@ -40,6 +43,10 @@ import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities
provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository,
},
{
provide: ORGANIZATION_REPOSITORY,
useClass: TypeOrmOrganizationRepository,
},
],
exports: [AuthService, JwtStrategy, PassportModule],
})

View File

@ -4,14 +4,18 @@ import {
ConflictException,
Logger,
Inject,
BadRequestException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2';
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { User, UserRole } from '@domain/entities/user.entity';
import { OrganizationRepository, ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
import { Organization, OrganizationType } from '@domain/entities/organization.entity';
import { v4 as uuidv4 } from 'uuid';
import { DEFAULT_ORG_ID } from '@infrastructure/persistence/typeorm/seeds/test-organizations.seed';
import { RegisterOrganizationDto } from '../dto/auth-login.dto';
export interface JwtPayload {
sub: string; // user ID
@ -27,7 +31,9 @@ export class AuthService {
constructor(
@Inject(USER_REPOSITORY)
private readonly userRepository: UserRepository, // ✅ Correct injection
private readonly userRepository: UserRepository,
@Inject(ORGANIZATION_REPOSITORY)
private readonly organizationRepository: OrganizationRepository,
private readonly jwtService: JwtService,
private readonly configService: ConfigService
) {}
@ -40,7 +46,8 @@ export class AuthService {
password: string,
firstName: string,
lastName: string,
organizationId?: string
organizationId?: string,
organizationData?: RegisterOrganizationDto
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
this.logger.log(`Registering new user: ${email}`);
@ -57,8 +64,11 @@ export class AuthService {
parallelism: 4,
});
// Validate or generate organization ID
const finalOrganizationId = this.validateOrGenerateOrganizationId(organizationId);
// Determine organization ID:
// 1. If organizationId is provided (invited user), use it
// 2. If organizationData is provided (new user), create a new organization
// 3. Otherwise, use default organization
const finalOrganizationId = await this.resolveOrganizationId(organizationId, organizationData);
const user = User.create({
id: uuidv4(),
@ -209,20 +219,80 @@ export class AuthService {
}
/**
* Validate or generate a valid organization ID
* If provided ID is invalid (not a UUID), generate a new one
* Resolve organization ID for registration
* 1. If organizationId is provided (invited user), validate and use it
* 2. If organizationData is provided (new user), create a new organization
* 3. Otherwise, throw an error (both are required)
*/
private validateOrGenerateOrganizationId(organizationId?: string): string {
// UUID v4 regex pattern
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
private async resolveOrganizationId(
organizationId?: string,
organizationData?: RegisterOrganizationDto
): Promise<string> {
// Case 1: Invited user - organizationId is provided
if (organizationId) {
this.logger.log(`Using existing organization for invited user: ${organizationId}`);
// Validate that the organization exists
const organization = await this.organizationRepository.findById(organizationId);
if (!organization) {
throw new BadRequestException('Invalid organization ID - organization does not exist');
}
if (!organization.isActive) {
throw new BadRequestException('Organization is not active');
}
if (organizationId && uuidRegex.test(organizationId)) {
return organizationId;
}
// Use default organization "Test Freight Forwarder Inc." if not provided
// This ID comes from the seed migration 1730000000006-SeedOrganizations
this.logger.log(`Using default organization ID for user registration: ${DEFAULT_ORG_ID}`);
return DEFAULT_ORG_ID;
// Case 2: New user - create a new organization
if (organizationData) {
this.logger.log(`Creating new organization for user registration: ${organizationData.name}`);
// Check if organization name already exists
const existingOrg = await this.organizationRepository.findByName(organizationData.name);
if (existingOrg) {
throw new ConflictException('An organization with this name already exists');
}
// Check if SCAC code already exists (for carriers)
if (organizationData.scac) {
const existingScac = await this.organizationRepository.findBySCAC(organizationData.scac);
if (existingScac) {
throw new ConflictException('An organization with this SCAC code already exists');
}
}
// Create new organization
const newOrganization = Organization.create({
id: uuidv4(),
name: organizationData.name,
type: organizationData.type,
scac: organizationData.scac,
address: {
street: organizationData.street,
city: organizationData.city,
state: organizationData.state,
postalCode: organizationData.postalCode,
country: organizationData.country,
},
documents: [],
isActive: true,
});
const savedOrganization = await this.organizationRepository.save(newOrganization);
this.logger.log(`New organization created: ${savedOrganization.id} - ${savedOrganization.name}`);
return savedOrganization.id;
}
// Case 3: Neither provided - error
throw new BadRequestException(
'Either organizationId (for invited users) or organization data (for new users) must be provided'
);
}
}

View File

@ -70,7 +70,8 @@ export class AuthController {
dto.password,
dto.firstName,
dto.lastName,
dto.organizationId
dto.organizationId,
dto.organization
);
return {

View File

@ -1,5 +1,7 @@
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, MinLength, IsOptional, ValidateNested, IsEnum, MaxLength, Matches } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { OrganizationType } from '@domain/entities/organization.entity';
export class LoginDto {
@ApiProperty({
@ -19,6 +21,84 @@ export class LoginDto {
password: string;
}
/**
* Organization data for registration (nested in RegisterDto)
*/
export class RegisterOrganizationDto {
@ApiProperty({
example: 'Acme Freight Forwarding',
description: 'Organization name',
minLength: 2,
maxLength: 200,
})
@IsString()
@MinLength(2)
@MaxLength(200)
name: string;
@ApiProperty({
example: OrganizationType.FREIGHT_FORWARDER,
description: 'Organization type',
enum: OrganizationType,
})
@IsEnum(OrganizationType)
type: OrganizationType;
@ApiProperty({
example: '123 Main Street',
description: 'Street address',
})
@IsString()
street: string;
@ApiProperty({
example: 'Rotterdam',
description: 'City',
})
@IsString()
city: string;
@ApiPropertyOptional({
example: 'South Holland',
description: 'State or province',
})
@IsString()
@IsOptional()
state?: string;
@ApiProperty({
example: '3000 AB',
description: 'Postal code',
})
@IsString()
postalCode: string;
@ApiProperty({
example: 'NL',
description: 'Country code (ISO 3166-1 alpha-2)',
minLength: 2,
maxLength: 2,
})
@IsString()
@MinLength(2)
@MaxLength(2)
@Matches(/^[A-Z]{2}$/, { message: 'Country must be a 2-letter ISO code (e.g., NL, US, CN)' })
country: string;
@ApiPropertyOptional({
example: 'MAEU',
description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)',
minLength: 4,
maxLength: 4,
})
@IsString()
@IsOptional()
@MinLength(4)
@MaxLength(4)
@Matches(/^[A-Z]{4}$/, { message: 'SCAC must be 4 uppercase letters' })
scac?: string;
}
export class RegisterDto {
@ApiProperty({
example: 'john.doe@acme.com',
@ -52,14 +132,24 @@ export class RegisterDto {
@MinLength(2, { message: 'Last name must be at least 2 characters' })
lastName: string;
@ApiProperty({
@ApiPropertyOptional({
example: '550e8400-e29b-41d4-a716-446655440000',
description: 'Organization ID (optional, will create default organization if not provided)',
description: 'Organization ID (optional - for invited users). If not provided, organization data must be provided.',
required: false,
})
@IsString()
@IsOptional()
organizationId?: string;
@ApiPropertyOptional({
description: 'Organization data (required if organizationId is not provided)',
type: RegisterOrganizationDto,
required: false,
})
@ValidateNested()
@Type(() => RegisterOrganizationDto)
@IsOptional()
organization?: RegisterOrganizationDto;
}
export class AuthResponseDto {

View File

@ -11,6 +11,7 @@ import { useRouter } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
import { register } from '@/lib/api';
import type { OrganizationType } from '@/types/api';
export default function RegisterPage() {
const router = useRouter();
@ -19,6 +20,16 @@ export default function RegisterPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
// Organization fields
const [organizationName, setOrganizationName] = useState('');
const [organizationType, setOrganizationType] = useState<OrganizationType>('FREIGHT_FORWARDER');
const [street, setStreet] = useState('');
const [city, setCity] = useState('');
const [state, setState] = useState('');
const [postalCode, setPostalCode] = useState('');
const [country, setCountry] = useState('FR');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
@ -38,6 +49,17 @@ export default function RegisterPage() {
return;
}
// Validate organization fields
if (!organizationName.trim()) {
setError('Le nom de l\'organisation est requis');
return;
}
if (!street.trim() || !city.trim() || !postalCode.trim() || !country.trim()) {
setError('Tous les champs d\'adresse sont requis');
return;
}
setIsLoading(true);
try {
@ -46,7 +68,15 @@ export default function RegisterPage() {
password,
firstName,
lastName,
organizationId: 'a1234567-0000-4000-8000-000000000001', // Test Organization
organization: {
name: organizationName,
type: organizationType,
street,
city,
state: state || undefined,
postalCode,
country: country.toUpperCase(),
},
});
router.push('/dashboard');
} catch (err: any) {
@ -181,6 +211,131 @@ export default function RegisterPage() {
/>
</div>
{/* Organization Section */}
<div className="pt-4 border-t border-neutral-200">
<h3 className="text-h5 text-brand-navy mb-4">Informations de votre organisation</h3>
{/* Organization Name */}
<div className="mb-4">
<label htmlFor="organizationName" className="label">
Nom de l'organisation
</label>
<input
id="organizationName"
type="text"
required
value={organizationName}
onChange={e => setOrganizationName(e.target.value)}
className="input w-full"
placeholder="Acme Logistics"
disabled={isLoading}
/>
</div>
{/* Organization Type */}
<div className="mb-4">
<label htmlFor="organizationType" className="label">
Type d'organisation
</label>
<select
id="organizationType"
value={organizationType}
onChange={e => setOrganizationType(e.target.value as OrganizationType)}
className="input w-full"
disabled={isLoading}
>
<option value="FREIGHT_FORWARDER">Transitaire</option>
<option value="SHIPPER">Expéditeur</option>
<option value="CARRIER">Transporteur</option>
</select>
</div>
{/* Street Address */}
<div className="mb-4">
<label htmlFor="street" className="label">
Adresse
</label>
<input
id="street"
type="text"
required
value={street}
onChange={e => setStreet(e.target.value)}
className="input w-full"
placeholder="123 Rue de la République"
disabled={isLoading}
/>
</div>
{/* City & Postal Code */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label htmlFor="city" className="label">
Ville
</label>
<input
id="city"
type="text"
required
value={city}
onChange={e => setCity(e.target.value)}
className="input w-full"
placeholder="Paris"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="postalCode" className="label">
Code postal
</label>
<input
id="postalCode"
type="text"
required
value={postalCode}
onChange={e => setPostalCode(e.target.value)}
className="input w-full"
placeholder="75001"
disabled={isLoading}
/>
</div>
</div>
{/* State & Country */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="state" className="label">
Région (optionnel)
</label>
<input
id="state"
type="text"
value={state}
onChange={e => setState(e.target.value)}
className="input w-full"
placeholder="Île-de-France"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="country" className="label">
Pays (code ISO)
</label>
<input
id="country"
type="text"
required
value={country}
onChange={e => setCountry(e.target.value)}
className="input w-full"
placeholder="FR"
maxLength={2}
disabled={isLoading}
/>
</div>
</div>
</div>
{/* Submit Button */}
<button
type="submit"

View File

@ -8,12 +8,24 @@
// Authentication
// ============================================================================
export interface RegisterOrganizationData {
name: string;
type: OrganizationType;
street: string;
city: string;
state?: string;
postalCode: string;
country: string;
scac?: string;
}
export interface RegisterRequest {
email: string;
password: string;
firstName: string;
lastName: string;
organizationId: string;
organizationId?: string; // For invited users
organization?: RegisterOrganizationData; // For new users
}
export interface LoginRequest {