diff --git a/apps/backend/src/application/auth/auth.module.ts b/apps/backend/src/application/auth/auth.module.ts index 6f514a3..bdb3bbf 100644 --- a/apps/backend/src/application/auth/auth.module.ts +++ b/apps/backend/src/application/auth/auth.module.ts @@ -10,10 +10,16 @@ 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 { INVITATION_TOKEN_REPOSITORY } from '@domain/ports/out/invitation-token.repository'; import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository'; import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository'; +import { TypeOrmInvitationTokenRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-invitation-token.repository'; import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity'; import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity'; +import { InvitationTokenOrmEntity } from '../../infrastructure/persistence/typeorm/entities/invitation-token.orm-entity'; +import { InvitationService } from '../services/invitation.service'; +import { InvitationsController } from '../controllers/invitations.controller'; +import { EmailModule } from '../../infrastructure/email/email.module'; @Module({ imports: [ @@ -33,12 +39,16 @@ import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/ }), // 👇 Add this to register TypeORM repositories - TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity]), + TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, InvitationTokenOrmEntity]), + + // Email module for sending invitations + EmailModule, ], - controllers: [AuthController], + controllers: [AuthController, InvitationsController], providers: [ AuthService, JwtStrategy, + InvitationService, { provide: USER_REPOSITORY, useClass: TypeOrmUserRepository, @@ -47,6 +57,10 @@ import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/ provide: ORGANIZATION_REPOSITORY, useClass: TypeOrmOrganizationRepository, }, + { + provide: INVITATION_TOKEN_REPOSITORY, + useClass: TypeOrmInvitationTokenRepository, + }, ], exports: [AuthService, JwtStrategy, PassportModule], }) diff --git a/apps/backend/src/application/auth/auth.service.ts b/apps/backend/src/application/auth/auth.service.ts index 19b0e5c..e174d2f 100644 --- a/apps/backend/src/application/auth/auth.service.ts +++ b/apps/backend/src/application/auth/auth.service.ts @@ -47,7 +47,8 @@ export class AuthService { firstName: string, lastName: string, organizationId?: string, - organizationData?: RegisterOrganizationDto + organizationData?: RegisterOrganizationDto, + invitationRole?: string ): Promise<{ accessToken: string; refreshToken: string; user: any }> { this.logger.log(`Registering new user: ${email}`); @@ -70,6 +71,9 @@ export class AuthService { // 3. Otherwise, use default organization const finalOrganizationId = await this.resolveOrganizationId(organizationId, organizationData); + // Determine role: use invitation role if provided, otherwise default to USER + const userRole = invitationRole ? (invitationRole as UserRole) : UserRole.USER; + const user = User.create({ id: uuidv4(), organizationId: finalOrganizationId, @@ -77,7 +81,7 @@ export class AuthService { passwordHash, firstName, lastName, - role: UserRole.USER, + role: userRole, }); const savedUser = await this.userRepository.save(user); diff --git a/apps/backend/src/application/controllers/auth.controller.ts b/apps/backend/src/application/controllers/auth.controller.ts index 488a9d6..256490c 100644 --- a/apps/backend/src/application/controllers/auth.controller.ts +++ b/apps/backend/src/application/controllers/auth.controller.ts @@ -17,6 +17,7 @@ import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { UserMapper } from '../mappers/user.mapper'; +import { InvitationService } from '../services/invitation.service'; /** * Authentication Controller @@ -33,7 +34,8 @@ import { UserMapper } from '../mappers/user.mapper'; export class AuthController { constructor( private readonly authService: AuthService, - @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository + @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, + private readonly invitationService: InvitationService ) {} /** @@ -65,15 +67,41 @@ export class AuthController { description: 'Validation error (invalid email, weak password, etc.)', }) async register(@Body() dto: RegisterDto): Promise { + // If invitation token is provided, verify and use it + let invitationOrganizationId: string | undefined; + let invitationRole: string | undefined; + + if (dto.invitationToken) { + const invitation = await this.invitationService.verifyInvitation(dto.invitationToken); + + // Verify email matches invitation + if (invitation.email.toLowerCase() !== dto.email.toLowerCase()) { + throw new NotFoundException('Invitation email does not match registration email'); + } + + invitationOrganizationId = invitation.organizationId; + invitationRole = invitation.role; + + // Override firstName/lastName from invitation if not provided + dto.firstName = dto.firstName || invitation.firstName; + dto.lastName = dto.lastName || invitation.lastName; + } + const result = await this.authService.register( dto.email, dto.password, dto.firstName, dto.lastName, - dto.organizationId, - dto.organization + invitationOrganizationId || dto.organizationId, + dto.organization, + invitationRole ); + // Mark invitation as used if provided + if (dto.invitationToken) { + await this.invitationService.markInvitationAsUsed(dto.invitationToken); + } + return { accessToken: result.accessToken, refreshToken: result.refreshToken, diff --git a/apps/backend/src/application/controllers/invitations.controller.ts b/apps/backend/src/application/controllers/invitations.controller.ts new file mode 100644 index 0000000..642dc97 --- /dev/null +++ b/apps/backend/src/application/controllers/invitations.controller.ts @@ -0,0 +1,187 @@ +import { + Controller, + Post, + Get, + Body, + UseGuards, + HttpCode, + HttpStatus, + Logger, + Param, + ParseUUIDPipe, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, +} from '@nestjs/swagger'; +import { InvitationService } from '../services/invitation.service'; +import { CreateInvitationDto, InvitationResponseDto, VerifyInvitationDto } from '../dto/invitation.dto'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { RolesGuard } from '../guards/roles.guard'; +import { Roles } from '../decorators/roles.decorator'; +import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; +import { Public } from '../decorators/public.decorator'; +import { UserRole } from '@domain/entities/user.entity'; + +/** + * Invitations Controller + * + * Handles user invitation endpoints: + * - POST /invitations - Create invitation (admin/manager) + * - GET /invitations/verify/:token - Verify invitation (public) + * - GET /invitations - List organization invitations (admin/manager) + */ +@ApiTags('Invitations') +@Controller('invitations') +export class InvitationsController { + private readonly logger = new Logger(InvitationsController.name); + + constructor(private readonly invitationService: InvitationService) {} + + /** + * Create invitation and send email + */ + @Post() + @HttpCode(HttpStatus.CREATED) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'manager') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Create invitation', + description: 'Send an invitation email to a new user. Admin/manager only.', + }) + @ApiResponse({ + status: 201, + description: 'Invitation created successfully', + type: InvitationResponseDto, + }) + @ApiResponse({ + status: 403, + description: 'Forbidden - requires admin or manager role', + }) + @ApiResponse({ + status: 409, + description: 'Conflict - user or active invitation already exists', + }) + async createInvitation( + @Body() dto: CreateInvitationDto, + @CurrentUser() user: UserPayload + ): Promise { + this.logger.log(`[User: ${user.email}] Creating invitation for: ${dto.email}`); + + const invitation = await this.invitationService.createInvitation( + dto.email, + dto.firstName, + dto.lastName, + dto.role as unknown as UserRole, + user.organizationId, + user.id + ); + + return { + id: invitation.id, + token: invitation.token, + email: invitation.email, + firstName: invitation.firstName, + lastName: invitation.lastName, + role: invitation.role, + organizationId: invitation.organizationId, + expiresAt: invitation.expiresAt, + isUsed: invitation.isUsed, + usedAt: invitation.usedAt, + createdAt: invitation.createdAt, + }; + } + + /** + * Verify invitation token + */ + @Get('verify/:token') + @Public() + @ApiOperation({ + summary: 'Verify invitation token', + description: 'Check if an invitation token is valid and not expired. Public endpoint.', + }) + @ApiParam({ + name: 'token', + description: 'Invitation token', + example: 'abc123def456', + }) + @ApiResponse({ + status: 200, + description: 'Invitation is valid', + type: InvitationResponseDto, + }) + @ApiResponse({ + status: 404, + description: 'Invitation not found', + }) + @ApiResponse({ + status: 400, + description: 'Invitation expired or already used', + }) + async verifyInvitation(@Param('token') token: string): Promise { + this.logger.log(`Verifying invitation token: ${token}`); + + const invitation = await this.invitationService.verifyInvitation(token); + + return { + id: invitation.id, + token: invitation.token, + email: invitation.email, + firstName: invitation.firstName, + lastName: invitation.lastName, + role: invitation.role, + organizationId: invitation.organizationId, + expiresAt: invitation.expiresAt, + isUsed: invitation.isUsed, + usedAt: invitation.usedAt, + createdAt: invitation.createdAt, + }; + } + + /** + * List organization invitations + */ + @Get() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'manager') + @ApiBearerAuth() + @ApiOperation({ + summary: 'List invitations', + description: 'Get all invitations for the current organization. Admin/manager only.', + }) + @ApiResponse({ + status: 200, + description: 'Invitations retrieved successfully', + type: [InvitationResponseDto], + }) + @ApiResponse({ + status: 403, + description: 'Forbidden - requires admin or manager role', + }) + async listInvitations(@CurrentUser() user: UserPayload): Promise { + this.logger.log(`[User: ${user.email}] Listing invitations for organization`); + + const invitations = await this.invitationService.getOrganizationInvitations( + user.organizationId + ); + + return invitations.map(invitation => ({ + id: invitation.id, + token: invitation.token, + email: invitation.email, + firstName: invitation.firstName, + lastName: invitation.lastName, + role: invitation.role, + organizationId: invitation.organizationId, + expiresAt: invitation.expiresAt, + isUsed: invitation.isUsed, + usedAt: invitation.usedAt, + createdAt: invitation.createdAt, + })); + } +} diff --git a/apps/backend/src/application/controllers/users.controller.ts b/apps/backend/src/application/controllers/users.controller.ts index 62bebdf..469de6e 100644 --- a/apps/backend/src/application/controllers/users.controller.ts +++ b/apps/backend/src/application/controllers/users.controller.ts @@ -106,6 +106,11 @@ export class UsersController { ): Promise { this.logger.log(`[User: ${user.email}] Creating user: ${dto.email} (${dto.role})`); + // Authorization: Only ADMIN can assign ADMIN role + if (dto.role === 'ADMIN' && user.role !== 'admin') { + throw new ForbiddenException('Only platform administrators can create users with ADMIN role'); + } + // Authorization: Managers can only create users in their own organization if (user.role === 'manager' && dto.organizationId !== user.organizationId) { throw new ForbiddenException('You can only create users in your own organization'); @@ -233,6 +238,11 @@ export class UsersController { throw new NotFoundException(`User ${id} not found`); } + // Authorization: Only ADMIN can assign ADMIN role + if (dto.role === 'ADMIN' && currentUser.role !== 'admin') { + throw new ForbiddenException('Only platform administrators can assign ADMIN role'); + } + // Authorization: Managers can only update users in their own organization if (currentUser.role === 'manager' && user.organizationId !== currentUser.organizationId) { throw new ForbiddenException('You can only update users in your own organization'); diff --git a/apps/backend/src/application/dto/auth-login.dto.ts b/apps/backend/src/application/dto/auth-login.dto.ts index 221ff35..01ec645 100644 --- a/apps/backend/src/application/dto/auth-login.dto.ts +++ b/apps/backend/src/application/dto/auth-login.dto.ts @@ -116,22 +116,33 @@ export class RegisterDto { @MinLength(12, { message: 'Password must be at least 12 characters' }) password: string; - @ApiProperty({ + @ApiPropertyOptional({ example: 'John', - description: 'First name', + description: 'First name (optional if using invitation token)', }) @IsString() + @IsOptional() @MinLength(2, { message: 'First name must be at least 2 characters' }) firstName: string; - @ApiProperty({ + @ApiPropertyOptional({ example: 'Doe', - description: 'Last name', + description: 'Last name (optional if using invitation token)', }) @IsString() + @IsOptional() @MinLength(2, { message: 'Last name must be at least 2 characters' }) lastName: string; + @ApiPropertyOptional({ + example: 'abc123def456', + description: 'Invitation token (for invited users)', + required: false, + }) + @IsString() + @IsOptional() + invitationToken?: string; + @ApiPropertyOptional({ example: '550e8400-e29b-41d4-a716-446655440000', description: 'Organization ID (optional - for invited users). If not provided, organization data must be provided.', @@ -142,7 +153,7 @@ export class RegisterDto { organizationId?: string; @ApiPropertyOptional({ - description: 'Organization data (required if organizationId is not provided)', + description: 'Organization data (required if organizationId and invitationToken are not provided)', type: RegisterOrganizationDto, required: false, }) diff --git a/apps/backend/src/application/dto/invitation.dto.ts b/apps/backend/src/application/dto/invitation.dto.ts new file mode 100644 index 0000000..07aa0c4 --- /dev/null +++ b/apps/backend/src/application/dto/invitation.dto.ts @@ -0,0 +1,159 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEmail, IsString, MinLength, IsEnum, IsOptional } from 'class-validator'; + +export enum InvitationRole { + MANAGER = 'MANAGER', + USER = 'USER', + VIEWER = 'VIEWER', +} + +/** + * Create Invitation DTO + */ +export class CreateInvitationDto { + @ApiProperty({ + example: 'jane.doe@acme.com', + description: 'Email address of the person to invite', + }) + @IsEmail({}, { message: 'Invalid email format' }) + email: string; + + @ApiProperty({ + example: 'Jane', + description: 'First name', + minLength: 2, + }) + @IsString() + @MinLength(2, { message: 'First name must be at least 2 characters' }) + firstName: string; + + @ApiProperty({ + example: 'Doe', + description: 'Last name', + minLength: 2, + }) + @IsString() + @MinLength(2, { message: 'Last name must be at least 2 characters' }) + lastName: string; + + @ApiProperty({ + example: InvitationRole.USER, + description: 'Role to assign to the invited user', + enum: InvitationRole, + }) + @IsEnum(InvitationRole) + role: InvitationRole; +} + +/** + * Invitation Response DTO + */ +export class InvitationResponseDto { + @ApiProperty({ + example: '550e8400-e29b-41d4-a716-446655440000', + description: 'Invitation ID', + }) + id: string; + + @ApiProperty({ + example: 'abc123def456', + description: 'Invitation token', + }) + token: string; + + @ApiProperty({ + example: 'jane.doe@acme.com', + description: 'Email address', + }) + email: string; + + @ApiProperty({ + example: 'Jane', + description: 'First name', + }) + firstName: string; + + @ApiProperty({ + example: 'Doe', + description: 'Last name', + }) + lastName: string; + + @ApiProperty({ + example: InvitationRole.USER, + description: 'Role', + enum: InvitationRole, + }) + role: string; + + @ApiProperty({ + example: '550e8400-e29b-41d4-a716-446655440000', + description: 'Organization ID', + }) + organizationId: string; + + @ApiProperty({ + example: '2025-12-01T00:00:00Z', + description: 'Expiration date', + }) + expiresAt: Date; + + @ApiProperty({ + example: false, + description: 'Whether the invitation has been used', + }) + isUsed: boolean; + + @ApiPropertyOptional({ + example: '2025-11-24T10:00:00Z', + description: 'Date when invitation was used', + }) + usedAt?: Date; + + @ApiProperty({ + example: '2025-11-20T10:00:00Z', + description: 'Creation date', + }) + createdAt: Date; +} + +/** + * Verify Invitation DTO + */ +export class VerifyInvitationDto { + @ApiProperty({ + example: 'abc123def456', + description: 'Invitation token', + }) + @IsString() + token: string; +} + +/** + * Accept Invitation DTO (for registration) + */ +export class AcceptInvitationDto { + @ApiProperty({ + example: 'abc123def456', + description: 'Invitation token', + }) + @IsString() + token: string; + + @ApiProperty({ + example: 'SecurePassword123!', + description: 'Password (minimum 12 characters)', + minLength: 12, + }) + @IsString() + @MinLength(12, { message: 'Password must be at least 12 characters' }) + password: string; + + @ApiPropertyOptional({ + example: '+33612345678', + description: 'Phone number (optional)', + }) + @IsString() + @IsOptional() + phoneNumber?: string; +} diff --git a/apps/backend/src/application/guards/roles.guard.ts b/apps/backend/src/application/guards/roles.guard.ts index 55987d3..c12d05b 100644 --- a/apps/backend/src/application/guards/roles.guard.ts +++ b/apps/backend/src/application/guards/roles.guard.ts @@ -41,6 +41,10 @@ export class RolesGuard implements CanActivate { return false; } - return requiredRoles.includes(user.role); + // Case-insensitive role comparison + const userRole = user.role.toLowerCase(); + const requiredRolesLower = requiredRoles.map(r => r.toLowerCase()); + + return requiredRolesLower.includes(userRole); } } diff --git a/apps/backend/src/application/services/invitation.service.ts b/apps/backend/src/application/services/invitation.service.ts new file mode 100644 index 0000000..56e49c0 --- /dev/null +++ b/apps/backend/src/application/services/invitation.service.ts @@ -0,0 +1,206 @@ +import { + Injectable, + Inject, + Logger, + ConflictException, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InvitationTokenRepository, INVITATION_TOKEN_REPOSITORY } from '@domain/ports/out/invitation-token.repository'; +import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; +import { OrganizationRepository, ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository'; +import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; +import { InvitationToken } from '@domain/entities/invitation-token.entity'; +import { UserRole } from '@domain/entities/user.entity'; +import { v4 as uuidv4 } from 'uuid'; +import * as crypto from 'crypto'; + +@Injectable() +export class InvitationService { + private readonly logger = new Logger(InvitationService.name); + + constructor( + @Inject(INVITATION_TOKEN_REPOSITORY) + private readonly invitationRepository: InvitationTokenRepository, + @Inject(USER_REPOSITORY) + private readonly userRepository: UserRepository, + @Inject(ORGANIZATION_REPOSITORY) + private readonly organizationRepository: OrganizationRepository, + @Inject(EMAIL_PORT) + private readonly emailService: EmailPort, + private readonly configService: ConfigService + ) {} + + /** + * Create an invitation and send email + */ + async createInvitation( + email: string, + firstName: string, + lastName: string, + role: UserRole, + organizationId: string, + invitedById: string + ): Promise { + this.logger.log(`Creating invitation for ${email} in organization ${organizationId}`); + + // Check if user already exists + const existingUser = await this.userRepository.findByEmail(email); + if (existingUser) { + throw new ConflictException('A user with this email already exists'); + } + + // Check if there's already an active invitation for this email + const existingInvitation = await this.invitationRepository.findActiveByEmail(email); + if (existingInvitation) { + throw new ConflictException( + 'An active invitation for this email already exists. Please wait for it to expire or be used.' + ); + } + + // Generate unique token + const token = this.generateToken(); + + // Set expiration date (7 days from now) + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); + + // Create invitation token + const invitation = InvitationToken.create({ + id: uuidv4(), + token, + email, + firstName, + lastName, + role, + organizationId, + invitedById, + expiresAt, + }); + + // Save invitation + const savedInvitation = await this.invitationRepository.save(invitation); + + // Send invitation email (async - don't block on email sending) + this.logger.log(`[INVITATION] About to send email to ${email}...`); + this.sendInvitationEmail(savedInvitation).catch(err => { + this.logger.error(`[INVITATION] ❌ Failed to send invitation email to ${email}`, err); + this.logger.error(`[INVITATION] Error message: ${err?.message}`); + this.logger.error(`[INVITATION] Error stack: ${err?.stack?.substring(0, 500)}`); + }); + + this.logger.log(`Invitation created successfully for ${email}`); + + return savedInvitation; + } + + /** + * Verify invitation token + */ + async verifyInvitation(token: string): Promise { + const invitation = await this.invitationRepository.findByToken(token); + + if (!invitation) { + throw new NotFoundException('Invitation not found'); + } + + if (invitation.isUsed) { + throw new BadRequestException('This invitation has already been used'); + } + + if (invitation.isExpired()) { + throw new BadRequestException('This invitation has expired'); + } + + return invitation; + } + + /** + * Mark invitation as used + */ + async markInvitationAsUsed(token: string): Promise { + const invitation = await this.verifyInvitation(token); + + invitation.markAsUsed(); + + await this.invitationRepository.update(invitation); + + this.logger.log(`Invitation ${token} marked as used`); + } + + /** + * Get all invitations for an organization + */ + async getOrganizationInvitations(organizationId: string): Promise { + return this.invitationRepository.findByOrganization(organizationId); + } + + /** + * Generate a secure random token + */ + private generateToken(): string { + return crypto.randomBytes(32).toString('hex'); + } + + /** + * Send invitation email + */ + private async sendInvitationEmail(invitation: InvitationToken): Promise { + this.logger.log(`[INVITATION] 🚀 sendInvitationEmail called for ${invitation.email}`); + + const frontendUrl = this.configService.get('FRONTEND_URL', 'http://localhost:3000'); + const invitationLink = `${frontendUrl}/register?token=${invitation.token}`; + + this.logger.log(`[INVITATION] Frontend URL: ${frontendUrl}`); + this.logger.log(`[INVITATION] Invitation link: ${invitationLink}`); + + // Get organization details + this.logger.log(`[INVITATION] Fetching organization ${invitation.organizationId}...`); + const organization = await this.organizationRepository.findById(invitation.organizationId); + if (!organization) { + this.logger.error(`[INVITATION] ❌ Organization not found: ${invitation.organizationId}`); + throw new NotFoundException('Organization not found'); + } + this.logger.log(`[INVITATION] ✅ Organization found: ${organization.name}`); + + // Get inviter details + this.logger.log(`[INVITATION] Fetching inviter ${invitation.invitedById}...`); + const inviter = await this.userRepository.findById(invitation.invitedById); + if (!inviter) { + this.logger.error(`[INVITATION] ❌ Inviter not found: ${invitation.invitedById}`); + throw new NotFoundException('Inviter user not found'); + } + + const inviterName = `${inviter.firstName} ${inviter.lastName}`; + this.logger.log(`[INVITATION] ✅ Inviter found: ${inviterName}`); + + try { + this.logger.log(`[INVITATION] 📧 Calling emailService.sendInvitationWithToken...`); + await this.emailService.sendInvitationWithToken( + invitation.email, + invitation.firstName, + invitation.lastName, + organization.name, + inviterName, + invitationLink, + invitation.expiresAt + ); + + this.logger.log(`[INVITATION] ✅ Email sent successfully to ${invitation.email}`); + } catch (error) { + this.logger.error(`[INVITATION] ❌ Failed to send invitation email to ${invitation.email}`, error); + this.logger.error(`[INVITATION] Error details: ${JSON.stringify(error, null, 2)}`); + throw error; + } + } + + /** + * Cleanup expired invitations (can be called by a cron job) + */ + async cleanupExpiredInvitations(): Promise { + const count = await this.invitationRepository.deleteExpired(); + this.logger.log(`Cleaned up ${count} expired invitations`); + return count; + } +} diff --git a/apps/backend/src/domain/entities/invitation-token.entity.ts b/apps/backend/src/domain/entities/invitation-token.entity.ts new file mode 100644 index 0000000..b74b0ad --- /dev/null +++ b/apps/backend/src/domain/entities/invitation-token.entity.ts @@ -0,0 +1,158 @@ +/** + * InvitationToken Entity + * + * Represents an invitation token for user registration. + * + * Business Rules: + * - Tokens expire after 7 days by default + * - Token can only be used once + * - Email must be unique per active (non-used) invitation + */ + +import { UserRole } from './user.entity'; + +export interface InvitationTokenProps { + id: string; + token: string; // Unique random token (e.g., UUID) + email: string; + firstName: string; + lastName: string; + role: UserRole; + organizationId: string; + invitedById: string; // User ID who created the invitation + expiresAt: Date; + usedAt?: Date; + isUsed: boolean; + createdAt: Date; +} + +export class InvitationToken { + private readonly props: InvitationTokenProps; + + private constructor(props: InvitationTokenProps) { + this.props = props; + } + + /** + * Factory method to create a new InvitationToken + */ + static create( + props: Omit + ): InvitationToken { + const now = new Date(); + + // Validate token + if (!props.token || props.token.trim().length === 0) { + throw new Error('Invitation token cannot be empty.'); + } + + // Validate email format + if (!InvitationToken.isValidEmail(props.email)) { + throw new Error('Invalid email format.'); + } + + // Validate expiration date + if (props.expiresAt <= now) { + throw new Error('Expiration date must be in the future.'); + } + + return new InvitationToken({ + ...props, + isUsed: false, + createdAt: now, + }); + } + + /** + * Factory method to reconstitute from persistence + */ + static fromPersistence(props: InvitationTokenProps): InvitationToken { + return new InvitationToken(props); + } + + /** + * Validate email format + */ + private static isValidEmail(email: string): boolean { + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailPattern.test(email); + } + + // Getters + get id(): string { + return this.props.id; + } + + get token(): string { + return this.props.token; + } + + get email(): string { + return this.props.email; + } + + get firstName(): string { + return this.props.firstName; + } + + get lastName(): string { + return this.props.lastName; + } + + get role(): UserRole { + return this.props.role; + } + + get organizationId(): string { + return this.props.organizationId; + } + + get invitedById(): string { + return this.props.invitedById; + } + + get expiresAt(): Date { + return this.props.expiresAt; + } + + get usedAt(): Date | undefined { + return this.props.usedAt; + } + + get isUsed(): boolean { + return this.props.isUsed; + } + + get createdAt(): Date { + return this.props.createdAt; + } + + // Business methods + isExpired(): boolean { + return new Date() > this.props.expiresAt; + } + + isValid(): boolean { + return !this.props.isUsed && !this.isExpired(); + } + + markAsUsed(): void { + if (this.props.isUsed) { + throw new Error('Invitation token has already been used.'); + } + + if (this.isExpired()) { + throw new Error('Invitation token has expired.'); + } + + this.props.isUsed = true; + this.props.usedAt = new Date(); + } + + /** + * Convert to plain object for persistence + */ + toObject(): InvitationTokenProps { + return { ...this.props }; + } +} diff --git a/apps/backend/src/domain/ports/out/email.port.ts b/apps/backend/src/domain/ports/out/email.port.ts index f7885e1..6968789 100644 --- a/apps/backend/src/domain/ports/out/email.port.ts +++ b/apps/backend/src/domain/ports/out/email.port.ts @@ -56,7 +56,7 @@ export interface EmailPort { sendWelcomeEmail(email: string, firstName: string): Promise; /** - * Send user invitation email + * Send user invitation email (legacy - with temp password) */ sendUserInvitation( email: string, @@ -65,6 +65,19 @@ export interface EmailPort { tempPassword: string ): Promise; + /** + * Send invitation email with registration link (token-based) + */ + sendInvitationWithToken( + email: string, + firstName: string, + lastName: string, + organizationName: string, + inviterName: string, + invitationLink: string, + expiresAt: Date + ): Promise; + /** * Send CSV booking request email to carrier */ diff --git a/apps/backend/src/domain/ports/out/invitation-token.repository.ts b/apps/backend/src/domain/ports/out/invitation-token.repository.ts new file mode 100644 index 0000000..b3fcc59 --- /dev/null +++ b/apps/backend/src/domain/ports/out/invitation-token.repository.ts @@ -0,0 +1,42 @@ +/** + * InvitationToken Repository Port + * + * Defines the interface for InvitationToken persistence operations. + * This is a secondary port (output port) in hexagonal architecture. + */ + +import { InvitationToken } from '../../entities/invitation-token.entity'; + +export const INVITATION_TOKEN_REPOSITORY = 'InvitationTokenRepository'; + +export interface InvitationTokenRepository { + /** + * Save an invitation token entity + */ + save(invitationToken: InvitationToken): Promise; + + /** + * Find invitation token by token string + */ + findByToken(token: string): Promise; + + /** + * Find invitation token by email (only non-used, non-expired) + */ + findActiveByEmail(email: string): Promise; + + /** + * Find all invitation tokens by organization + */ + findByOrganization(organizationId: string): Promise; + + /** + * Delete expired invitation tokens + */ + deleteExpired(): Promise; + + /** + * Update an invitation token + */ + update(invitationToken: InvitationToken): Promise; +} diff --git a/apps/backend/src/infrastructure/email/email.adapter.ts b/apps/backend/src/infrastructure/email/email.adapter.ts index 93c1bc1..98a9e08 100644 --- a/apps/backend/src/infrastructure/email/email.adapter.ts +++ b/apps/backend/src/infrastructure/email/email.adapter.ts @@ -24,16 +24,18 @@ export class EmailAdapter implements EmailPort { private initializeTransporter(): void { const host = this.configService.get('SMTP_HOST', 'localhost'); - const port = this.configService.get('SMTP_PORT', 587); - const secure = this.configService.get('SMTP_SECURE', false); + const port = this.configService.get('SMTP_PORT', 2525); const user = this.configService.get('SMTP_USER'); const pass = this.configService.get('SMTP_PASS'); + // Simple Mailtrap configuration - exactly as documented this.transporter = nodemailer.createTransport({ host, port, - secure, - auth: user && pass ? { user, pass } : undefined, + auth: { + user, + pass, + }, }); this.logger.log(`Email adapter initialized with SMTP host: ${host}:${port}`); @@ -151,6 +153,67 @@ export class EmailAdapter implements EmailPort { }); } + async sendInvitationWithToken( + email: string, + firstName: string, + lastName: string, + organizationName: string, + inviterName: string, + invitationLink: string, + expiresAt: Date + ): Promise { + try { + this.logger.log(`[sendInvitationWithToken] Starting email generation for ${email}`); + + const expiresAtFormatted = expiresAt.toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + + this.logger.log(`[sendInvitationWithToken] Rendering template...`); + const html = await this.emailTemplates.renderInvitationWithToken({ + firstName, + lastName, + organizationName, + inviterName, + invitationLink, + expiresAt: expiresAtFormatted, + }); + + this.logger.log(`[sendInvitationWithToken] Template rendered, sending email to ${email}...`); + this.logger.log(`[sendInvitationWithToken] HTML size: ${html.length} bytes`); + + await this.send({ + to: email, + subject: `Invitation à rejoindre ${organizationName} sur Xpeditis`, + html, + }); + + this.logger.log(`Invitation email sent to ${email} for ${organizationName}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const errorCode = (error as any).code; + const errorResponse = (error as any).response; + const errorResponseCode = (error as any).responseCode; + const errorCommand = (error as any).command; + + this.logger.error(`[sendInvitationWithToken] ERROR MESSAGE: ${errorMessage}`); + this.logger.error(`[sendInvitationWithToken] ERROR CODE: ${errorCode}`); + this.logger.error(`[sendInvitationWithToken] ERROR RESPONSE: ${errorResponse}`); + this.logger.error(`[sendInvitationWithToken] ERROR RESPONSE CODE: ${errorResponseCode}`); + this.logger.error(`[sendInvitationWithToken] ERROR COMMAND: ${errorCommand}`); + + if (error instanceof Error && error.stack) { + this.logger.error(`[sendInvitationWithToken] STACK: ${error.stack.substring(0, 500)}`); + } + + throw error; + } + } + async sendCsvBookingRequest( carrierEmail: string, bookingData: { diff --git a/apps/backend/src/infrastructure/email/templates/email-templates.ts b/apps/backend/src/infrastructure/email/templates/email-templates.ts index e3b881c..a1c5cc7 100644 --- a/apps/backend/src/infrastructure/email/templates/email-templates.ts +++ b/apps/backend/src/infrastructure/email/templates/email-templates.ts @@ -498,4 +498,96 @@ export class EmailTemplates { const template = Handlebars.compile(html); return template(data); } + + /** + * Render invitation email with registration link + */ + async renderInvitationWithToken(data: { + firstName: string; + lastName: string; + organizationName: string; + inviterName: string; + invitationLink: string; + expiresAt: string; + }): Promise { + const mjmlTemplate = ` + + + + + + + + + + + 🚢 Bienvenue sur Xpeditis ! + + + + + Bonjour {{firstName}} {{lastName}}, + + + + {{inviterName}} vous invite à rejoindre {{organizationName}} sur la plateforme Xpeditis. + + + + Xpeditis est la solution complète pour gérer vos expéditions maritimes en ligne. Recherchez des tarifs, réservez des containers et suivez vos envois en temps réel. + + + + + + Créer mon compte + + + + + + Ou copiez ce lien dans votre navigateur: + + + {{invitationLink}} + + + + + + + + ⏱️ Cette invitation expire le {{expiresAt}} + + + Créez votre compte avant cette date pour rejoindre votre organisation. + + + + + + + + © 2025 Xpeditis. Tous droits réservés. + + + Si vous n'avez pas sollicité cette invitation, vous pouvez ignorer cet email. + + + + + + `; + + const { html } = mjml2html(mjmlTemplate); + const template = Handlebars.compile(html); + return template(data); + } } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/invitation-token.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/invitation-token.orm-entity.ts new file mode 100644 index 0000000..71ef937 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/invitation-token.orm-entity.ts @@ -0,0 +1,65 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { UserOrmEntity } from './user.orm-entity'; +import { OrganizationOrmEntity } from './organization.orm-entity'; + +@Entity('invitation_tokens') +export class InvitationTokenOrmEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + @Index('IDX_invitation_tokens_token') + token: string; + + @Column() + @Index('IDX_invitation_tokens_email') + email: string; + + @Column({ name: 'first_name' }) + firstName: string; + + @Column({ name: 'last_name' }) + lastName: string; + + @Column({ + type: 'enum', + enum: ['ADMIN', 'MANAGER', 'USER', 'VIEWER'], + default: 'USER', + }) + role: string; + + @Column({ name: 'organization_id' }) + organizationId: string; + + @ManyToOne(() => OrganizationOrmEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'organization_id' }) + organization: OrganizationOrmEntity; + + @Column({ name: 'invited_by_id' }) + invitedById: string; + + @ManyToOne(() => UserOrmEntity, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'invited_by_id' }) + invitedBy: UserOrmEntity; + + @Column({ name: 'expires_at', type: 'timestamp' }) + @Index('IDX_invitation_tokens_expires_at') + expiresAt: Date; + + @Column({ name: 'used_at', type: 'timestamp', nullable: true }) + usedAt: Date | null; + + @Column({ name: 'is_used', default: false }) + isUsed: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/invitation-token-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/invitation-token-orm.mapper.ts new file mode 100644 index 0000000..3b545be --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/invitation-token-orm.mapper.ts @@ -0,0 +1,53 @@ +/** + * InvitationToken ORM Mapper + * + * Maps between domain InvitationToken entity and TypeORM InvitationTokenOrmEntity + */ + +import { InvitationToken } from '@domain/entities/invitation-token.entity'; +import { UserRole } from '@domain/entities/user.entity'; +import { InvitationTokenOrmEntity } from '../entities/invitation-token.orm-entity'; + +export class InvitationTokenOrmMapper { + /** + * Map from ORM entity to domain entity + */ + static toDomain(ormEntity: InvitationTokenOrmEntity): InvitationToken { + return InvitationToken.fromPersistence({ + id: ormEntity.id, + token: ormEntity.token, + email: ormEntity.email, + firstName: ormEntity.firstName, + lastName: ormEntity.lastName, + role: ormEntity.role as UserRole, + organizationId: ormEntity.organizationId, + invitedById: ormEntity.invitedById, + expiresAt: ormEntity.expiresAt, + usedAt: ormEntity.usedAt || undefined, + isUsed: ormEntity.isUsed, + createdAt: ormEntity.createdAt, + }); + } + + /** + * Map from domain entity to ORM entity + */ + static toOrm(domain: InvitationToken): InvitationTokenOrmEntity { + const ormEntity = new InvitationTokenOrmEntity(); + + ormEntity.id = domain.id; + ormEntity.token = domain.token; + ormEntity.email = domain.email; + ormEntity.firstName = domain.firstName; + ormEntity.lastName = domain.lastName; + ormEntity.role = domain.role; + ormEntity.organizationId = domain.organizationId; + ormEntity.invitedById = domain.invitedById; + ormEntity.expiresAt = domain.expiresAt; + ormEntity.usedAt = domain.usedAt || null; + ormEntity.isUsed = domain.isUsed; + ormEntity.createdAt = domain.createdAt; + + return ormEntity; + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1732896000000-CreateInvitationTokens.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1732896000000-CreateInvitationTokens.ts new file mode 100644 index 0000000..c8ede3a --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1732896000000-CreateInvitationTokens.ts @@ -0,0 +1,115 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +export class CreateInvitationTokens1732896000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'invitation_tokens', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'uuid_generate_v4()', + }, + { + name: 'token', + type: 'varchar', + length: '255', + isUnique: true, + }, + { + name: 'email', + type: 'varchar', + length: '255', + }, + { + name: 'first_name', + type: 'varchar', + length: '100', + }, + { + name: 'last_name', + type: 'varchar', + length: '100', + }, + { + name: 'role', + type: 'enum', + enum: ['ADMIN', 'MANAGER', 'USER', 'VIEWER'], + default: "'USER'", + }, + { + name: 'organization_id', + type: 'uuid', + }, + { + name: 'invited_by_id', + type: 'uuid', + }, + { + name: 'expires_at', + type: 'timestamp', + }, + { + name: 'used_at', + type: 'timestamp', + isNullable: true, + }, + { + name: 'is_used', + type: 'boolean', + default: false, + }, + { + name: 'created_at', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + ], + }), + true + ); + + // Add foreign key for organization + await queryRunner.createForeignKey( + 'invitation_tokens', + new TableForeignKey({ + columnNames: ['organization_id'], + referencedColumnNames: ['id'], + referencedTableName: 'organizations', + onDelete: 'CASCADE', + }) + ); + + // Add foreign key for invited_by (user who sent the invitation) + await queryRunner.createForeignKey( + 'invitation_tokens', + new TableForeignKey({ + columnNames: ['invited_by_id'], + referencedColumnNames: ['id'], + referencedTableName: 'users', + onDelete: 'SET NULL', + }) + ); + + // Add index on token for fast lookup + await queryRunner.query( + `CREATE INDEX "IDX_invitation_tokens_token" ON "invitation_tokens" ("token")` + ); + + // Add index on email + await queryRunner.query( + `CREATE INDEX "IDX_invitation_tokens_email" ON "invitation_tokens" ("email")` + ); + + // Add index on expires_at for cleanup queries + await queryRunner.query( + `CREATE INDEX "IDX_invitation_tokens_expires_at" ON "invitation_tokens" ("expires_at")` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('invitation_tokens'); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-invitation-token.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-invitation-token.repository.ts new file mode 100644 index 0000000..696e405 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-invitation-token.repository.ts @@ -0,0 +1,86 @@ +/** + * TypeORM InvitationToken Repository + * + * Implements InvitationTokenRepository port using TypeORM + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThan } from 'typeorm'; +import { InvitationTokenRepository } from '@domain/ports/out/invitation-token.repository'; +import { InvitationToken } from '@domain/entities/invitation-token.entity'; +import { InvitationTokenOrmEntity } from '../entities/invitation-token.orm-entity'; +import { InvitationTokenOrmMapper } from '../mappers/invitation-token-orm.mapper'; + +@Injectable() +export class TypeOrmInvitationTokenRepository implements InvitationTokenRepository { + constructor( + @InjectRepository(InvitationTokenOrmEntity) + private readonly repository: Repository + ) {} + + async save(invitationToken: InvitationToken): Promise { + const ormEntity = InvitationTokenOrmMapper.toOrm(invitationToken); + const saved = await this.repository.save(ormEntity); + return InvitationTokenOrmMapper.toDomain(saved); + } + + async findByToken(token: string): Promise { + const ormEntity = await this.repository.findOne({ + where: { token }, + }); + + return ormEntity ? InvitationTokenOrmMapper.toDomain(ormEntity) : null; + } + + async findActiveByEmail(email: string): Promise { + const ormEntity = await this.repository.findOne({ + where: { + email, + isUsed: false, + }, + order: { + createdAt: 'DESC', + }, + }); + + if (!ormEntity) { + return null; + } + + const domain = InvitationTokenOrmMapper.toDomain(ormEntity); + + // Check if expired + if (domain.isExpired()) { + return null; + } + + return domain; + } + + async findByOrganization(organizationId: string): Promise { + const ormEntities = await this.repository.find({ + where: { organizationId }, + order: { + createdAt: 'DESC', + }, + }); + + return ormEntities.map(entity => InvitationTokenOrmMapper.toDomain(entity)); + } + + async deleteExpired(): Promise { + const result = await this.repository.delete({ + expiresAt: LessThan(new Date()), + isUsed: false, + }); + + return result.affected || 0; + } + + async update(invitationToken: InvitationToken): Promise { + const ormEntity = InvitationTokenOrmMapper.toOrm(invitationToken); + const updated = await this.repository.save(ormEntity); + return InvitationTokenOrmMapper.toDomain(updated); + } +} diff --git a/apps/frontend/app/dashboard/settings/users/page.tsx b/apps/frontend/app/dashboard/settings/users/page.tsx index b2e8679..ef5a208 100644 --- a/apps/frontend/app/dashboard/settings/users/page.tsx +++ b/apps/frontend/app/dashboard/settings/users/page.tsx @@ -8,7 +8,8 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { listUsers, createUser, updateUser, deleteUser } from '@/lib/api'; +import { listUsers, updateUser, deleteUser } from '@/lib/api'; +import { createInvitation } from '@/lib/api/invitations'; import { useAuth } from '@/lib/context/auth-context'; export default function UsersManagementPage() { @@ -19,9 +20,7 @@ export default function UsersManagementPage() { email: '', firstName: '', lastName: '', - role: 'USER' as 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER', - password: '', - phoneNumber: '', + role: 'USER' as 'MANAGER' | 'USER' | 'VIEWER', }); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); @@ -32,32 +31,28 @@ export default function UsersManagementPage() { }); const inviteMutation = useMutation({ - mutationFn: (data: typeof inviteForm & { organizationId: string }) => { - return createUser({ + mutationFn: (data: typeof inviteForm) => { + return createInvitation({ email: data.email, - password: data.password, firstName: data.firstName, lastName: data.lastName, role: data.role, - organizationId: data.organizationId, }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); - setSuccess('User invited successfully'); + setSuccess('Invitation sent successfully! The user will receive an email with a registration link.'); setShowInviteModal(false); setInviteForm({ email: '', firstName: '', lastName: '', role: 'USER', - password: '', - phoneNumber: '', }); - setTimeout(() => setSuccess(''), 3000); + setTimeout(() => setSuccess(''), 5000); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to invite user'); + setError(err.response?.data?.message || 'Failed to send invitation'); setTimeout(() => setError(''), 5000); }, }); @@ -109,17 +104,7 @@ export default function UsersManagementPage() { e.preventDefault(); setError(''); - if (!currentUser?.organizationId) { - setError('Organization ID not found. Please log in again.'); - return; - } - - if (!inviteForm.password || inviteForm.password.length < 8) { - setError('Password must be at least 8 characters long'); - return; - } - - inviteMutation.mutate({ ...inviteForm, organizationId: currentUser.organizationId }); + inviteMutation.mutate(inviteForm); }; const handleRoleChange = (userId: string, newRole: string) => { @@ -242,9 +227,9 @@ export default function UsersManagementPage() { className={`text-xs font-semibold rounded-full px-3 py-1 ${getRoleBadgeColor( user.role )}`} - disabled={changeRoleMutation.isPending} + disabled={changeRoleMutation.isPending || (user.role === 'ADMIN' && currentUser?.role !== 'ADMIN')} > - + {currentUser?.role === 'ADMIN' && } @@ -375,32 +360,6 @@ export default function UsersManagementPage() { /> -
- - setInviteForm({ ...inviteForm, password: e.target.value })} - className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border" - placeholder="Minimum 8 characters" - /> -

- Provide a temporary password (minimum 8 characters). User can change it after first login. -

-
- -
- - setInviteForm({ ...inviteForm, phoneNumber: e.target.value })} - className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border" - /> -
-
+ {currentUser?.role !== 'ADMIN' && ( +

+ Only platform administrators can assign the ADMIN role +

+ )}
diff --git a/apps/frontend/app/register/page.tsx b/apps/frontend/app/register/page.tsx index 89f03c6..3e3da18 100644 --- a/apps/frontend/app/register/page.tsx +++ b/apps/frontend/app/register/page.tsx @@ -6,15 +6,18 @@ 'use client'; -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { useState, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import Image from 'next/image'; import { register } from '@/lib/api'; +import { verifyInvitation, type InvitationResponse } from '@/lib/api/invitations'; import type { OrganizationType } from '@/types/api'; export default function RegisterPage() { const router = useRouter(); + const searchParams = useSearchParams(); + const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [email, setEmail] = useState(''); @@ -33,6 +36,34 @@ export default function RegisterPage() { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); + // Invitation-related state + const [invitationToken, setInvitationToken] = useState(null); + const [invitation, setInvitation] = useState(null); + const [isVerifyingInvitation, setIsVerifyingInvitation] = useState(false); + + // Verify invitation token on mount + useEffect(() => { + const token = searchParams.get('token'); + if (token) { + setIsVerifyingInvitation(true); + verifyInvitation(token) + .then(invitationData => { + setInvitation(invitationData); + setInvitationToken(token); + // Pre-fill user information from invitation + setEmail(invitationData.email); + setFirstName(invitationData.firstName); + setLastName(invitationData.lastName); + }) + .catch(err => { + setError('Le lien d\'invitation est invalide ou expiré.'); + }) + .finally(() => { + setIsVerifyingInvitation(false); + }); + } + }, [searchParams]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); @@ -49,15 +80,17 @@ export default function RegisterPage() { return; } - // Validate organization fields - if (!organizationName.trim()) { - setError('Le nom de l\'organisation est requis'); - return; - } + // Validate organization fields only if NOT using invitation + if (!invitationToken) { + 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; + if (!street.trim() || !city.trim() || !postalCode.trim() || !country.trim()) { + setError('Tous les champs d\'adresse sont requis'); + return; + } } setIsLoading(true); @@ -68,15 +101,20 @@ export default function RegisterPage() { password, firstName, lastName, - organization: { - name: organizationName, - type: organizationType, - street, - city, - state: state || undefined, - postalCode, - country: country.toUpperCase(), - }, + // If invitation token exists, use it; otherwise provide organization data + ...(invitationToken + ? { invitationToken } + : { + organization: { + name: organizationName, + type: organizationType, + street, + city, + state: state || undefined, + postalCode, + country: country.toUpperCase(), + }, + }), }); router.push('/dashboard'); } catch (err: any) { @@ -107,12 +145,32 @@ export default function RegisterPage() { {/* Header */}
-

Créer un compte

+

+ {invitation ? 'Accepter l\'invitation' : 'Créer un compte'} +

- Commencez votre essai gratuit dès aujourd'hui + {invitation + ? `Vous avez été invité à rejoindre une organisation` + : 'Commencez votre essai gratuit dès aujourd\'hui'}

+ {/* Verifying Invitation Loading */} + {isVerifyingInvitation && ( +
+

Vérification de l'invitation...

+
+ )} + + {/* Success Message for Invitation */} + {invitation && !error && ( +
+

+ Invitation valide ! Créez votre mot de passe pour rejoindre l'organisation. +

+
+ )} + {/* Error Message */} {error && (
@@ -136,7 +194,7 @@ export default function RegisterPage() { onChange={e => setFirstName(e.target.value)} className="input w-full" placeholder="Jean" - disabled={isLoading} + disabled={isLoading || !!invitation} />
@@ -151,7 +209,7 @@ export default function RegisterPage() { onChange={e => setLastName(e.target.value)} className="input w-full" placeholder="Dupont" - disabled={isLoading} + disabled={isLoading || !!invitation} />
@@ -170,7 +228,7 @@ export default function RegisterPage() { className="input w-full" placeholder="jean.dupont@entreprise.com" autoComplete="email" - disabled={isLoading} + disabled={isLoading || !!invitation} /> @@ -211,9 +269,10 @@ export default function RegisterPage() { /> - {/* Organization Section */} -
-

Informations de votre organisation

+ {/* Organization Section - Only show if NOT using invitation */} + {!invitation && ( +
+

Informations de votre organisation

{/* Organization Name */}
@@ -334,7 +393,8 @@ export default function RegisterPage() { />
-
+ + )} {/* Submit Button */}