send invitations

This commit is contained in:
David 2025-11-30 13:39:32 +01:00
parent a34c850e67
commit cca6eda9d3
21 changed files with 1484 additions and 99 deletions

View File

@ -10,10 +10,16 @@ import { AuthController } from '../controllers/auth.controller';
// Import domain and infrastructure dependencies // Import domain and infrastructure dependencies
import { USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.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 { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-organization.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 { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.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({ @Module({
imports: [ imports: [
@ -33,12 +39,16 @@ import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/
}), }),
// 👇 Add this to register TypeORM repositories // 👇 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: [ providers: [
AuthService, AuthService,
JwtStrategy, JwtStrategy,
InvitationService,
{ {
provide: USER_REPOSITORY, provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository, useClass: TypeOrmUserRepository,
@ -47,6 +57,10 @@ import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/
provide: ORGANIZATION_REPOSITORY, provide: ORGANIZATION_REPOSITORY,
useClass: TypeOrmOrganizationRepository, useClass: TypeOrmOrganizationRepository,
}, },
{
provide: INVITATION_TOKEN_REPOSITORY,
useClass: TypeOrmInvitationTokenRepository,
},
], ],
exports: [AuthService, JwtStrategy, PassportModule], exports: [AuthService, JwtStrategy, PassportModule],
}) })

View File

@ -47,7 +47,8 @@ export class AuthService {
firstName: string, firstName: string,
lastName: string, lastName: string,
organizationId?: string, organizationId?: string,
organizationData?: RegisterOrganizationDto organizationData?: RegisterOrganizationDto,
invitationRole?: string
): Promise<{ accessToken: string; refreshToken: string; user: any }> { ): Promise<{ accessToken: string; refreshToken: string; user: any }> {
this.logger.log(`Registering new user: ${email}`); this.logger.log(`Registering new user: ${email}`);
@ -70,6 +71,9 @@ export class AuthService {
// 3. Otherwise, use default organization // 3. Otherwise, use default organization
const finalOrganizationId = await this.resolveOrganizationId(organizationId, organizationData); 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({ const user = User.create({
id: uuidv4(), id: uuidv4(),
organizationId: finalOrganizationId, organizationId: finalOrganizationId,
@ -77,7 +81,7 @@ export class AuthService {
passwordHash, passwordHash,
firstName, firstName,
lastName, lastName,
role: UserRole.USER, role: userRole,
}); });
const savedUser = await this.userRepository.save(user); const savedUser = await this.userRepository.save(user);

View File

@ -17,6 +17,7 @@ import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { UserMapper } from '../mappers/user.mapper'; import { UserMapper } from '../mappers/user.mapper';
import { InvitationService } from '../services/invitation.service';
/** /**
* Authentication Controller * Authentication Controller
@ -33,7 +34,8 @@ import { UserMapper } from '../mappers/user.mapper';
export class AuthController { export class AuthController {
constructor( constructor(
private readonly authService: AuthService, 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.)', description: 'Validation error (invalid email, weak password, etc.)',
}) })
async register(@Body() dto: RegisterDto): Promise<AuthResponseDto> { async register(@Body() dto: RegisterDto): Promise<AuthResponseDto> {
// 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( const result = await this.authService.register(
dto.email, dto.email,
dto.password, dto.password,
dto.firstName, dto.firstName,
dto.lastName, dto.lastName,
dto.organizationId, invitationOrganizationId || dto.organizationId,
dto.organization dto.organization,
invitationRole
); );
// Mark invitation as used if provided
if (dto.invitationToken) {
await this.invitationService.markInvitationAsUsed(dto.invitationToken);
}
return { return {
accessToken: result.accessToken, accessToken: result.accessToken,
refreshToken: result.refreshToken, refreshToken: result.refreshToken,

View File

@ -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<InvitationResponseDto> {
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<InvitationResponseDto> {
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<InvitationResponseDto[]> {
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,
}));
}
}

View File

@ -106,6 +106,11 @@ export class UsersController {
): Promise<UserResponseDto> { ): Promise<UserResponseDto> {
this.logger.log(`[User: ${user.email}] Creating user: ${dto.email} (${dto.role})`); 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 // Authorization: Managers can only create users in their own organization
if (user.role === 'manager' && dto.organizationId !== user.organizationId) { if (user.role === 'manager' && dto.organizationId !== user.organizationId) {
throw new ForbiddenException('You can only create users in your own organization'); 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`); 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 // Authorization: Managers can only update users in their own organization
if (currentUser.role === 'manager' && user.organizationId !== currentUser.organizationId) { if (currentUser.role === 'manager' && user.organizationId !== currentUser.organizationId) {
throw new ForbiddenException('You can only update users in your own organization'); throw new ForbiddenException('You can only update users in your own organization');

View File

@ -116,22 +116,33 @@ export class RegisterDto {
@MinLength(12, { message: 'Password must be at least 12 characters' }) @MinLength(12, { message: 'Password must be at least 12 characters' })
password: string; password: string;
@ApiProperty({ @ApiPropertyOptional({
example: 'John', example: 'John',
description: 'First name', description: 'First name (optional if using invitation token)',
}) })
@IsString() @IsString()
@IsOptional()
@MinLength(2, { message: 'First name must be at least 2 characters' }) @MinLength(2, { message: 'First name must be at least 2 characters' })
firstName: string; firstName: string;
@ApiProperty({ @ApiPropertyOptional({
example: 'Doe', example: 'Doe',
description: 'Last name', description: 'Last name (optional if using invitation token)',
}) })
@IsString() @IsString()
@IsOptional()
@MinLength(2, { message: 'Last name must be at least 2 characters' }) @MinLength(2, { message: 'Last name must be at least 2 characters' })
lastName: string; lastName: string;
@ApiPropertyOptional({
example: 'abc123def456',
description: 'Invitation token (for invited users)',
required: false,
})
@IsString()
@IsOptional()
invitationToken?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
example: '550e8400-e29b-41d4-a716-446655440000', example: '550e8400-e29b-41d4-a716-446655440000',
description: 'Organization ID (optional - for invited users). If not provided, organization data must be provided.', description: 'Organization ID (optional - for invited users). If not provided, organization data must be provided.',
@ -142,7 +153,7 @@ export class RegisterDto {
organizationId?: string; organizationId?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Organization data (required if organizationId is not provided)', description: 'Organization data (required if organizationId and invitationToken are not provided)',
type: RegisterOrganizationDto, type: RegisterOrganizationDto,
required: false, required: false,
}) })

View File

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

View File

@ -41,6 +41,10 @@ export class RolesGuard implements CanActivate {
return false; 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);
} }
} }

View File

@ -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<InvitationToken> {
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<InvitationToken> {
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<void> {
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<InvitationToken[]> {
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<void> {
this.logger.log(`[INVITATION] 🚀 sendInvitationEmail called for ${invitation.email}`);
const frontendUrl = this.configService.get<string>('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<number> {
const count = await this.invitationRepository.deleteExpired();
this.logger.log(`Cleaned up ${count} expired invitations`);
return count;
}
}

View File

@ -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<InvitationTokenProps, 'createdAt' | 'isUsed' | 'usedAt'>
): 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 };
}
}

View File

@ -56,7 +56,7 @@ export interface EmailPort {
sendWelcomeEmail(email: string, firstName: string): Promise<void>; sendWelcomeEmail(email: string, firstName: string): Promise<void>;
/** /**
* Send user invitation email * Send user invitation email (legacy - with temp password)
*/ */
sendUserInvitation( sendUserInvitation(
email: string, email: string,
@ -65,6 +65,19 @@ export interface EmailPort {
tempPassword: string tempPassword: string
): Promise<void>; ): Promise<void>;
/**
* Send invitation email with registration link (token-based)
*/
sendInvitationWithToken(
email: string,
firstName: string,
lastName: string,
organizationName: string,
inviterName: string,
invitationLink: string,
expiresAt: Date
): Promise<void>;
/** /**
* Send CSV booking request email to carrier * Send CSV booking request email to carrier
*/ */

View File

@ -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<InvitationToken>;
/**
* Find invitation token by token string
*/
findByToken(token: string): Promise<InvitationToken | null>;
/**
* Find invitation token by email (only non-used, non-expired)
*/
findActiveByEmail(email: string): Promise<InvitationToken | null>;
/**
* Find all invitation tokens by organization
*/
findByOrganization(organizationId: string): Promise<InvitationToken[]>;
/**
* Delete expired invitation tokens
*/
deleteExpired(): Promise<number>;
/**
* Update an invitation token
*/
update(invitationToken: InvitationToken): Promise<InvitationToken>;
}

View File

@ -24,16 +24,18 @@ export class EmailAdapter implements EmailPort {
private initializeTransporter(): void { private initializeTransporter(): void {
const host = this.configService.get<string>('SMTP_HOST', 'localhost'); const host = this.configService.get<string>('SMTP_HOST', 'localhost');
const port = this.configService.get<number>('SMTP_PORT', 587); const port = this.configService.get<number>('SMTP_PORT', 2525);
const secure = this.configService.get<boolean>('SMTP_SECURE', false);
const user = this.configService.get<string>('SMTP_USER'); const user = this.configService.get<string>('SMTP_USER');
const pass = this.configService.get<string>('SMTP_PASS'); const pass = this.configService.get<string>('SMTP_PASS');
// Simple Mailtrap configuration - exactly as documented
this.transporter = nodemailer.createTransport({ this.transporter = nodemailer.createTransport({
host, host,
port, port,
secure, auth: {
auth: user && pass ? { user, pass } : undefined, user,
pass,
},
}); });
this.logger.log(`Email adapter initialized with SMTP host: ${host}:${port}`); 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<void> {
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( async sendCsvBookingRequest(
carrierEmail: string, carrierEmail: string,
bookingData: { bookingData: {

View File

@ -498,4 +498,96 @@ export class EmailTemplates {
const template = Handlebars.compile(html); const template = Handlebars.compile(html);
return template(data); 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<string> {
const mjmlTemplate = `
<mjml>
<mj-head>
<mj-attributes>
<mj-all font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" />
</mj-attributes>
</mj-head>
<mj-body background-color="#f4f4f4">
<mj-section background-color="#ffffff" padding="40px 20px">
<mj-column>
<mj-text font-size="28px" font-weight="bold" color="#0066cc" align="center">
🚢 Bienvenue sur Xpeditis !
</mj-text>
<mj-divider border-color="#0066cc" border-width="3px" padding="20px 0" />
<mj-text font-size="16px" line-height="1.8">
Bonjour <strong>{{firstName}} {{lastName}}</strong>,
</mj-text>
<mj-text font-size="16px" line-height="1.8">
{{inviterName}} vous invite à rejoindre <strong>{{organizationName}}</strong> sur la plateforme Xpeditis.
</mj-text>
<mj-text font-size="15px" color="#666666" line-height="1.6">
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.
</mj-text>
<mj-spacer height="30px" />
<mj-button
background-color="#0066cc"
href="{{invitationLink}}"
font-size="16px"
font-weight="bold"
border-radius="5px"
padding="15px 40px"
>
Créer mon compte
</mj-button>
<mj-spacer height="20px" />
<mj-text font-size="13px" color="#999999" align="center">
Ou copiez ce lien dans votre navigateur:
</mj-text>
<mj-text font-size="12px" color="#0066cc" align="center">
{{invitationLink}}
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#fff8e1" padding="20px">
<mj-column>
<mj-text font-size="14px" color="#f57c00" font-weight="bold">
Cette invitation expire le {{expiresAt}}
</mj-text>
<mj-text font-size="13px" color="#666666">
Créez votre compte avant cette date pour rejoindre votre organisation.
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#f4f4f4" padding="20px">
<mj-column>
<mj-text font-size="12px" color="#666666" align="center">
© 2025 Xpeditis. Tous droits réservés.
</mj-text>
<mj-text font-size="11px" color="#999999" align="center">
Si vous n'avez pas sollicité cette invitation, vous pouvez ignorer cet email.
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
`;
const { html } = mjml2html(mjmlTemplate);
const template = Handlebars.compile(html);
return template(data);
}
} }

View File

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

View File

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

View File

@ -0,0 +1,115 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
export class CreateInvitationTokens1732896000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.dropTable('invitation_tokens');
}
}

View File

@ -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<InvitationTokenOrmEntity>
) {}
async save(invitationToken: InvitationToken): Promise<InvitationToken> {
const ormEntity = InvitationTokenOrmMapper.toOrm(invitationToken);
const saved = await this.repository.save(ormEntity);
return InvitationTokenOrmMapper.toDomain(saved);
}
async findByToken(token: string): Promise<InvitationToken | null> {
const ormEntity = await this.repository.findOne({
where: { token },
});
return ormEntity ? InvitationTokenOrmMapper.toDomain(ormEntity) : null;
}
async findActiveByEmail(email: string): Promise<InvitationToken | null> {
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<InvitationToken[]> {
const ormEntities = await this.repository.find({
where: { organizationId },
order: {
createdAt: 'DESC',
},
});
return ormEntities.map(entity => InvitationTokenOrmMapper.toDomain(entity));
}
async deleteExpired(): Promise<number> {
const result = await this.repository.delete({
expiresAt: LessThan(new Date()),
isUsed: false,
});
return result.affected || 0;
}
async update(invitationToken: InvitationToken): Promise<InvitationToken> {
const ormEntity = InvitationTokenOrmMapper.toOrm(invitationToken);
const updated = await this.repository.save(ormEntity);
return InvitationTokenOrmMapper.toDomain(updated);
}
}

View File

@ -8,7 +8,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 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'; import { useAuth } from '@/lib/context/auth-context';
export default function UsersManagementPage() { export default function UsersManagementPage() {
@ -19,9 +20,7 @@ export default function UsersManagementPage() {
email: '', email: '',
firstName: '', firstName: '',
lastName: '', lastName: '',
role: 'USER' as 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER', role: 'USER' as 'MANAGER' | 'USER' | 'VIEWER',
password: '',
phoneNumber: '',
}); });
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
@ -32,32 +31,28 @@ export default function UsersManagementPage() {
}); });
const inviteMutation = useMutation({ const inviteMutation = useMutation({
mutationFn: (data: typeof inviteForm & { organizationId: string }) => { mutationFn: (data: typeof inviteForm) => {
return createUser({ return createInvitation({
email: data.email, email: data.email,
password: data.password,
firstName: data.firstName, firstName: data.firstName,
lastName: data.lastName, lastName: data.lastName,
role: data.role, role: data.role,
organizationId: data.organizationId,
}); });
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['users'] });
setSuccess('User invited successfully'); setSuccess('Invitation sent successfully! The user will receive an email with a registration link.');
setShowInviteModal(false); setShowInviteModal(false);
setInviteForm({ setInviteForm({
email: '', email: '',
firstName: '', firstName: '',
lastName: '', lastName: '',
role: 'USER', role: 'USER',
password: '',
phoneNumber: '',
}); });
setTimeout(() => setSuccess(''), 3000); setTimeout(() => setSuccess(''), 5000);
}, },
onError: (err: any) => { onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to invite user'); setError(err.response?.data?.message || 'Failed to send invitation');
setTimeout(() => setError(''), 5000); setTimeout(() => setError(''), 5000);
}, },
}); });
@ -109,17 +104,7 @@ export default function UsersManagementPage() {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
if (!currentUser?.organizationId) { inviteMutation.mutate(inviteForm);
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 });
}; };
const handleRoleChange = (userId: string, newRole: string) => { 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( className={`text-xs font-semibold rounded-full px-3 py-1 ${getRoleBadgeColor(
user.role user.role
)}`} )}`}
disabled={changeRoleMutation.isPending} disabled={changeRoleMutation.isPending || (user.role === 'ADMIN' && currentUser?.role !== 'ADMIN')}
> >
<option value="ADMIN">Admin</option> {currentUser?.role === 'ADMIN' && <option value="ADMIN">Admin</option>}
<option value="MANAGER">Manager</option> <option value="MANAGER">Manager</option>
<option value="USER">User</option> <option value="USER">User</option>
<option value="VIEWER">Viewer</option> <option value="VIEWER">Viewer</option>
@ -375,32 +360,6 @@ export default function UsersManagementPage() {
/> />
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700">Password *</label>
<input
type="password"
required
minLength={8}
value={inviteForm.password}
onChange={e => 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"
/>
<p className="mt-1 text-xs text-gray-500">
Provide a temporary password (minimum 8 characters). User can change it after first login.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Phone Number</label>
<input
type="tel"
value={inviteForm.phoneNumber}
onChange={e => 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"
/>
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">Role *</label> <label className="block text-sm font-medium text-gray-700">Role *</label>
<select <select
@ -410,9 +369,14 @@ export default function UsersManagementPage() {
> >
<option value="USER">User</option> <option value="USER">User</option>
<option value="MANAGER">Manager</option> <option value="MANAGER">Manager</option>
<option value="ADMIN">Admin</option> {currentUser?.role === 'ADMIN' && <option value="ADMIN">Admin</option>}
<option value="VIEWER">Viewer</option> <option value="VIEWER">Viewer</option>
</select> </select>
{currentUser?.role !== 'ADMIN' && (
<p className="mt-1 text-xs text-gray-500">
Only platform administrators can assign the ADMIN role
</p>
)}
</div> </div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense"> <div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">

View File

@ -6,15 +6,18 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { register } from '@/lib/api'; import { register } from '@/lib/api';
import { verifyInvitation, type InvitationResponse } from '@/lib/api/invitations';
import type { OrganizationType } from '@/types/api'; import type { OrganizationType } from '@/types/api';
export default function RegisterPage() { export default function RegisterPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const [firstName, setFirstName] = useState(''); const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState(''); const [lastName, setLastName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@ -33,6 +36,34 @@ export default function RegisterPage() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
// Invitation-related state
const [invitationToken, setInvitationToken] = useState<string | null>(null);
const [invitation, setInvitation] = useState<InvitationResponse | null>(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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
@ -49,15 +80,17 @@ export default function RegisterPage() {
return; return;
} }
// Validate organization fields // Validate organization fields only if NOT using invitation
if (!organizationName.trim()) { if (!invitationToken) {
setError('Le nom de l\'organisation est requis'); if (!organizationName.trim()) {
return; setError('Le nom de l\'organisation est requis');
} return;
}
if (!street.trim() || !city.trim() || !postalCode.trim() || !country.trim()) { if (!street.trim() || !city.trim() || !postalCode.trim() || !country.trim()) {
setError('Tous les champs d\'adresse sont requis'); setError('Tous les champs d\'adresse sont requis');
return; return;
}
} }
setIsLoading(true); setIsLoading(true);
@ -68,15 +101,20 @@ export default function RegisterPage() {
password, password,
firstName, firstName,
lastName, lastName,
organization: { // If invitation token exists, use it; otherwise provide organization data
name: organizationName, ...(invitationToken
type: organizationType, ? { invitationToken }
street, : {
city, organization: {
state: state || undefined, name: organizationName,
postalCode, type: organizationType,
country: country.toUpperCase(), street,
}, city,
state: state || undefined,
postalCode,
country: country.toUpperCase(),
},
}),
}); });
router.push('/dashboard'); router.push('/dashboard');
} catch (err: any) { } catch (err: any) {
@ -107,12 +145,32 @@ export default function RegisterPage() {
{/* Header */} {/* Header */}
<div className="mb-8"> <div className="mb-8">
<h1 className="text-h1 text-brand-navy mb-2">Créer un compte</h1> <h1 className="text-h1 text-brand-navy mb-2">
{invitation ? 'Accepter l\'invitation' : 'Créer un compte'}
</h1>
<p className="text-body text-neutral-600"> <p className="text-body text-neutral-600">
Commencez votre essai gratuit dès aujourd'hui {invitation
? `Vous avez été invité à rejoindre une organisation`
: 'Commencez votre essai gratuit dès aujourd\'hui'}
</p> </p>
</div> </div>
{/* Verifying Invitation Loading */}
{isVerifyingInvitation && (
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-body-sm text-blue-800">Vérification de l'invitation...</p>
</div>
)}
{/* Success Message for Invitation */}
{invitation && !error && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<p className="text-body-sm text-green-800">
Invitation valide ! Créez votre mot de passe pour rejoindre l'organisation.
</p>
</div>
)}
{/* Error Message */} {/* Error Message */}
{error && ( {error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg"> <div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
@ -136,7 +194,7 @@ export default function RegisterPage() {
onChange={e => setFirstName(e.target.value)} onChange={e => setFirstName(e.target.value)}
className="input w-full" className="input w-full"
placeholder="Jean" placeholder="Jean"
disabled={isLoading} disabled={isLoading || !!invitation}
/> />
</div> </div>
<div> <div>
@ -151,7 +209,7 @@ export default function RegisterPage() {
onChange={e => setLastName(e.target.value)} onChange={e => setLastName(e.target.value)}
className="input w-full" className="input w-full"
placeholder="Dupont" placeholder="Dupont"
disabled={isLoading} disabled={isLoading || !!invitation}
/> />
</div> </div>
</div> </div>
@ -170,7 +228,7 @@ export default function RegisterPage() {
className="input w-full" className="input w-full"
placeholder="jean.dupont@entreprise.com" placeholder="jean.dupont@entreprise.com"
autoComplete="email" autoComplete="email"
disabled={isLoading} disabled={isLoading || !!invitation}
/> />
</div> </div>
@ -211,9 +269,10 @@ export default function RegisterPage() {
/> />
</div> </div>
{/* Organization Section */} {/* Organization Section - Only show if NOT using invitation */}
<div className="pt-4 border-t border-neutral-200"> {!invitation && (
<h3 className="text-h5 text-brand-navy mb-4">Informations de votre organisation</h3> <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 */} {/* Organization Name */}
<div className="mb-4"> <div className="mb-4">
@ -334,7 +393,8 @@ export default function RegisterPage() {
/> />
</div> </div>
</div> </div>
</div> </div>
)}
{/* Submit Button */} {/* Submit Button */}
<button <button

View File

@ -0,0 +1,51 @@
import { get, post } from './client';
/**
* Invitation API Types
*/
export interface CreateInvitationRequest {
email: string;
firstName: string;
lastName: string;
role: 'MANAGER' | 'USER' | 'VIEWER';
}
export interface InvitationResponse {
id: string;
email: string;
firstName: string;
lastName: string;
role: string;
organizationId: string;
invitedById: string;
token: string;
expiresAt: string;
isUsed: boolean;
usedAt?: string;
createdAt: string;
}
/**
* Create a new invitation
* Sends an email to the invited user with a registration link
*/
export async function createInvitation(
data: CreateInvitationRequest
): Promise<InvitationResponse> {
return post<InvitationResponse>('/api/v1/invitations', data);
}
/**
* Verify an invitation token
* Checks if the token is valid and not expired
*/
export async function verifyInvitation(token: string): Promise<InvitationResponse> {
return get<InvitationResponse>(`/api/v1/invitations/verify/${token}`, false);
}
/**
* List all invitations for the current organization
*/
export async function listInvitations(): Promise<InvitationResponse[]> {
return get<InvitationResponse[]>('/api/v1/invitations');
}