send invitations
This commit is contained in:
parent
a34c850e67
commit
cca6eda9d3
@ -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],
|
||||
})
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<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(
|
||||
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,
|
||||
|
||||
@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -106,6 +106,11 @@ export class UsersController {
|
||||
): Promise<UserResponseDto> {
|
||||
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');
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
159
apps/backend/src/application/dto/invitation.dto.ts
Normal file
159
apps/backend/src/application/dto/invitation.dto.ts
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
206
apps/backend/src/application/services/invitation.service.ts
Normal file
206
apps/backend/src/application/services/invitation.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
158
apps/backend/src/domain/entities/invitation-token.entity.ts
Normal file
158
apps/backend/src/domain/entities/invitation-token.entity.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@ -56,7 +56,7 @@ export interface EmailPort {
|
||||
sendWelcomeEmail(email: string, firstName: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 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<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
|
||||
*/
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
@ -24,16 +24,18 @@ export class EmailAdapter implements EmailPort {
|
||||
|
||||
private initializeTransporter(): void {
|
||||
const host = this.configService.get<string>('SMTP_HOST', 'localhost');
|
||||
const port = this.configService.get<number>('SMTP_PORT', 587);
|
||||
const secure = this.configService.get<boolean>('SMTP_SECURE', false);
|
||||
const port = this.configService.get<number>('SMTP_PORT', 2525);
|
||||
const user = this.configService.get<string>('SMTP_USER');
|
||||
const pass = this.configService.get<string>('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<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(
|
||||
carrierEmail: string,
|
||||
bookingData: {
|
||||
|
||||
@ -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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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')}
|
||||
>
|
||||
<option value="ADMIN">Admin</option>
|
||||
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Admin</option>}
|
||||
<option value="MANAGER">Manager</option>
|
||||
<option value="USER">User</option>
|
||||
<option value="VIEWER">Viewer</option>
|
||||
@ -375,32 +360,6 @@ export default function UsersManagementPage() {
|
||||
/>
|
||||
</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>
|
||||
<label className="block text-sm font-medium text-gray-700">Role *</label>
|
||||
<select
|
||||
@ -410,9 +369,14 @@ export default function UsersManagementPage() {
|
||||
>
|
||||
<option value="USER">User</option>
|
||||
<option value="MANAGER">Manager</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Admin</option>}
|
||||
<option value="VIEWER">Viewer</option>
|
||||
</select>
|
||||
{currentUser?.role !== 'ADMIN' && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Only platform administrators can assign the ADMIN role
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
||||
|
||||
@ -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<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) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
@ -49,7 +80,8 @@ export default function RegisterPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate organization fields
|
||||
// Validate organization fields only if NOT using invitation
|
||||
if (!invitationToken) {
|
||||
if (!organizationName.trim()) {
|
||||
setError('Le nom de l\'organisation est requis');
|
||||
return;
|
||||
@ -59,6 +91,7 @@ export default function RegisterPage() {
|
||||
setError('Tous les champs d\'adresse sont requis');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
@ -68,6 +101,10 @@ export default function RegisterPage() {
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
// If invitation token exists, use it; otherwise provide organization data
|
||||
...(invitationToken
|
||||
? { invitationToken }
|
||||
: {
|
||||
organization: {
|
||||
name: organizationName,
|
||||
type: organizationType,
|
||||
@ -77,6 +114,7 @@ export default function RegisterPage() {
|
||||
postalCode,
|
||||
country: country.toUpperCase(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
router.push('/dashboard');
|
||||
} catch (err: any) {
|
||||
@ -107,12 +145,32 @@ export default function RegisterPage() {
|
||||
|
||||
{/* Header */}
|
||||
<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">
|
||||
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>
|
||||
</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 && (
|
||||
<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)}
|
||||
className="input w-full"
|
||||
placeholder="Jean"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || !!invitation}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@ -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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -170,7 +228,7 @@ export default function RegisterPage() {
|
||||
className="input w-full"
|
||||
placeholder="jean.dupont@entreprise.com"
|
||||
autoComplete="email"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || !!invitation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -211,7 +269,8 @@ export default function RegisterPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Organization Section */}
|
||||
{/* Organization Section - Only show if NOT using invitation */}
|
||||
{!invitation && (
|
||||
<div className="pt-4 border-t border-neutral-200">
|
||||
<h3 className="text-h5 text-brand-navy mb-4">Informations de votre organisation</h3>
|
||||
|
||||
@ -335,6 +394,7 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
|
||||
51
apps/frontend/src/lib/api/invitations.ts
Normal file
51
apps/frontend/src/lib/api/invitations.ts
Normal 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');
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user