fix email and documentation api
This commit is contained in:
parent
ccc64b939a
commit
0e4c0d7785
@ -35,6 +35,7 @@ npm run frontend:dev # http://localhost:3000
|
||||
# Backend (from apps/backend/)
|
||||
npm test # Unit tests (Jest)
|
||||
npm test -- booking.entity.spec.ts # Single file
|
||||
npm test -- --testNamePattern="should create" # Filter by test name
|
||||
npm run test:cov # With coverage
|
||||
npm run test:integration # Integration tests (needs DB/Redis, 30s timeout)
|
||||
npm run test:e2e # E2E tests
|
||||
@ -75,6 +76,7 @@ npm run migration:revert
|
||||
```bash
|
||||
npm run backend:build # NestJS build with tsc-alias for path resolution
|
||||
npm run frontend:build # Next.js production build (standalone output)
|
||||
npm run clean # Remove all node_modules, dist, .next directories
|
||||
```
|
||||
|
||||
## Local Infrastructure
|
||||
@ -210,6 +212,12 @@ All other routes redirect to `/login?redirect=<pathname>` when the cookie is abs
|
||||
- `@Roles()` — role-based access control
|
||||
- `@CurrentUser()` — inject authenticated user
|
||||
|
||||
### API Key Authentication
|
||||
A second auth mechanism alongside JWT. `ApiKey` domain entity (`domain/entities/api-key.entity.ts`) — keys are hashed with Argon2. `ApiKeyGuard` in `application/guards/` checks the `x-api-key` header. Routes can accept either JWT or API key; see `admin.controller.ts` for examples.
|
||||
|
||||
### WebSocket (Real-time Notifications)
|
||||
Socket.IO gateway at `application/gateways/notifications.gateway.ts`. Clients connect to `/` namespace with a JWT bearer token in the handshake auth. Server emits `notification` events. The frontend `useNotifications` hook handles subscriptions.
|
||||
|
||||
### Carrier Connectors
|
||||
Five carrier connectors (Maersk, MSC, CMA CGM, Hapag-Lloyd, ONE) extending `base-carrier.connector.ts`, each with request/response mappers. Circuit breaker via `opossum` (5s timeout).
|
||||
|
||||
|
||||
@ -37,11 +37,13 @@ MICROSOFT_CALLBACK_URL=http://localhost:4000/api/v1/auth/microsoft/callback
|
||||
APP_URL=http://localhost:3000
|
||||
|
||||
# Email (SMTP)
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
SMTP_HOST=smtp-relay.brevo.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=ton-email@brevo.com
|
||||
SMTP_PASS=ta-cle-smtp-brevo
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=your-sendgrid-api-key
|
||||
|
||||
# SMTP_FROM devient le fallback uniquement (chaque méthode a son propre from maintenant)
|
||||
SMTP_FROM=noreply@xpeditis.com
|
||||
|
||||
# AWS S3 / Storage (or MinIO for development)
|
||||
|
||||
@ -26,6 +26,9 @@ import { PappersSiretAdapter } from '@infrastructure/external/pappers-siret.adap
|
||||
// CSV Booking Service
|
||||
import { CsvBookingsModule } from '../csv-bookings.module';
|
||||
|
||||
// Email
|
||||
import { EmailModule } from '@infrastructure/email/email.module';
|
||||
|
||||
/**
|
||||
* Admin Module
|
||||
*
|
||||
@ -37,6 +40,7 @@ import { CsvBookingsModule } from '../csv-bookings.module';
|
||||
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]),
|
||||
ConfigModule,
|
||||
CsvBookingsModule,
|
||||
EmailModule,
|
||||
],
|
||||
controllers: [AdminController],
|
||||
providers: [
|
||||
|
||||
@ -53,6 +53,9 @@ import {
|
||||
SIRET_VERIFICATION_PORT,
|
||||
} from '@domain/ports/out/siret-verification.port';
|
||||
|
||||
// Email imports
|
||||
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
||||
|
||||
/**
|
||||
* Admin Controller
|
||||
*
|
||||
@ -76,7 +79,8 @@ export class AdminController {
|
||||
private readonly csvBookingRepository: TypeOrmCsvBookingRepository,
|
||||
private readonly csvBookingService: CsvBookingService,
|
||||
@Inject(SIRET_VERIFICATION_PORT)
|
||||
private readonly siretVerificationPort: SiretVerificationPort
|
||||
private readonly siretVerificationPort: SiretVerificationPort,
|
||||
@Inject(EMAIL_PORT) private readonly emailPort: EmailPort
|
||||
) {}
|
||||
|
||||
// ==================== USERS ENDPOINTS ====================
|
||||
@ -608,6 +612,30 @@ export class AdminController {
|
||||
return this.csvBookingToDto(updatedBooking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend carrier email for a booking (admin only)
|
||||
*
|
||||
* Manually sends the booking request email to the carrier.
|
||||
* Useful when the automatic email failed (SMTP error) or for testing without Stripe.
|
||||
*/
|
||||
@Post('bookings/:id/resend-carrier-email')
|
||||
@ApiOperation({
|
||||
summary: 'Resend carrier email (Admin only)',
|
||||
description:
|
||||
'Manually resend the booking request email to the carrier. Works regardless of payment status.',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
||||
@ApiResponse({ status: 200, description: 'Email sent to carrier' })
|
||||
@ApiNotFoundResponse({ description: 'Booking not found' })
|
||||
async resendCarrierEmail(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: UserPayload
|
||||
) {
|
||||
this.logger.log(`[ADMIN: ${user.email}] Resending carrier email for booking: ${id}`);
|
||||
await this.csvBookingService.resendCarrierEmail(id);
|
||||
return { success: true, message: 'Email sent to carrier' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate bank transfer for a booking (admin only)
|
||||
*
|
||||
@ -701,6 +729,50 @@ export class AdminController {
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== EMAIL TEST ENDPOINT ====================
|
||||
|
||||
/**
|
||||
* Send a test email to verify SMTP configuration (admin only)
|
||||
*
|
||||
* Returns the exact SMTP error in the response instead of only logging it.
|
||||
*/
|
||||
@Post('test-email')
|
||||
@ApiOperation({
|
||||
summary: 'Send test email (Admin only)',
|
||||
description:
|
||||
'Sends a simple test email to the given address. Returns the exact SMTP error if delivery fails — useful for diagnosing Brevo/SMTP issues.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Email sent successfully' })
|
||||
@ApiResponse({ status: 400, description: 'SMTP error — check the message field' })
|
||||
async sendTestEmail(
|
||||
@Body() body: { to: string },
|
||||
@CurrentUser() user: UserPayload
|
||||
) {
|
||||
if (!body?.to) {
|
||||
throw new BadRequestException('Field "to" is required');
|
||||
}
|
||||
|
||||
this.logger.log(`[ADMIN: ${user.email}] Sending test email to ${body.to}`);
|
||||
|
||||
try {
|
||||
await this.emailPort.send({
|
||||
to: body.to,
|
||||
subject: '[Xpeditis] Test SMTP',
|
||||
html: `<p>Email de test envoyé depuis le panel admin par <strong>${user.email}</strong>.</p><p>Si vous lisez ceci, la configuration SMTP fonctionne correctement.</p>`,
|
||||
text: `Email de test envoyé par ${user.email}. Si vous lisez ceci, le SMTP fonctionne.`,
|
||||
});
|
||||
|
||||
this.logger.log(`[ADMIN] Test email sent successfully to ${body.to}`);
|
||||
return { success: true, message: `Email envoyé avec succès à ${body.to}` };
|
||||
} catch (error: any) {
|
||||
this.logger.error(`[ADMIN] Test email FAILED to ${body.to}: ${error?.message}`, error?.stack);
|
||||
throw new BadRequestException(
|
||||
`Échec SMTP — ${error?.message ?? 'erreur inconnue'}. ` +
|
||||
`Code: ${error?.code ?? 'N/A'}, Response: ${error?.response ?? 'N/A'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DOCUMENTS ENDPOINTS ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -2,6 +2,7 @@ import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Delete,
|
||||
Body,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
@ -137,6 +138,29 @@ export class InvitationsController {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel (delete) a pending invitation
|
||||
*/
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'manager')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Cancel invitation',
|
||||
description: 'Delete a pending invitation. Admin/manager only.',
|
||||
})
|
||||
@ApiResponse({ status: 204, description: 'Invitation cancelled' })
|
||||
@ApiResponse({ status: 404, description: 'Invitation not found' })
|
||||
@ApiResponse({ status: 400, description: 'Invitation already used' })
|
||||
async cancelInvitation(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<void> {
|
||||
this.logger.log(`[User: ${user.email}] Cancelling invitation: ${id}`);
|
||||
await this.invitationService.cancelInvitation(id, user.organizationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* List organization invitations
|
||||
*/
|
||||
|
||||
@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString, MinLength, IsEnum, IsOptional } from 'class-validator';
|
||||
|
||||
export enum InvitationRole {
|
||||
ADMIN = 'ADMIN',
|
||||
MANAGER = 'MANAGER',
|
||||
USER = 'USER',
|
||||
VIEWER = 'VIEWER',
|
||||
|
||||
@ -447,6 +447,50 @@ export class CsvBookingService {
|
||||
return this.toResponseDto(updatedBooking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend carrier email for a booking (admin action)
|
||||
* Works regardless of payment status — useful for retrying failed emails or testing without Stripe.
|
||||
*/
|
||||
async resendCarrierEmail(bookingId: string): Promise<void> {
|
||||
const booking = await this.csvBookingRepository.findById(bookingId);
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||
}
|
||||
|
||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
||||
where: { id: bookingId },
|
||||
});
|
||||
const bookingNumber = ormBooking?.bookingNumber;
|
||||
const documentPassword = bookingNumber
|
||||
? this.extractPasswordFromBookingNumber(bookingNumber)
|
||||
: undefined;
|
||||
|
||||
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
|
||||
bookingId: booking.id,
|
||||
bookingNumber: bookingNumber || '',
|
||||
documentPassword: documentPassword || '',
|
||||
origin: booking.origin.getValue(),
|
||||
destination: booking.destination.getValue(),
|
||||
volumeCBM: booking.volumeCBM,
|
||||
weightKG: booking.weightKG,
|
||||
palletCount: booking.palletCount,
|
||||
priceUSD: booking.priceUSD,
|
||||
priceEUR: booking.priceEUR,
|
||||
primaryCurrency: booking.primaryCurrency,
|
||||
transitDays: booking.transitDays,
|
||||
containerType: booking.containerType,
|
||||
documents: booking.documents.map(doc => ({
|
||||
type: doc.type,
|
||||
fileName: doc.fileName,
|
||||
})),
|
||||
confirmationToken: booking.confirmationToken,
|
||||
notes: booking.notes,
|
||||
});
|
||||
|
||||
this.logger.log(`[ADMIN] Carrier email resent to ${booking.carrierEmail} for booking ${bookingId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin validates bank transfer — confirms receipt and activates booking
|
||||
* Transitions booking from PENDING_BANK_TRANSFER → PENDING then sends email to carrier
|
||||
|
||||
@ -220,6 +220,25 @@ export class InvitationService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel (delete) a pending invitation
|
||||
*/
|
||||
async cancelInvitation(invitationId: string, organizationId: string): Promise<void> {
|
||||
const invitations = await this.invitationRepository.findByOrganization(organizationId);
|
||||
const invitation = invitations.find(inv => inv.id === invitationId);
|
||||
|
||||
if (!invitation) {
|
||||
throw new NotFoundException('Invitation not found');
|
||||
}
|
||||
|
||||
if (invitation.isUsed) {
|
||||
throw new BadRequestException('Cannot delete an invitation that has already been used');
|
||||
}
|
||||
|
||||
await this.invitationRepository.deleteById(invitationId);
|
||||
this.logger.log(`Invitation ${invitationId} cancelled`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired invitations (can be called by a cron job)
|
||||
*/
|
||||
|
||||
@ -15,6 +15,7 @@ export interface EmailAttachment {
|
||||
|
||||
export interface EmailOptions {
|
||||
to: string | string[];
|
||||
from?: string;
|
||||
cc?: string | string[];
|
||||
bcc?: string | string[];
|
||||
replyTo?: string;
|
||||
|
||||
@ -35,6 +35,11 @@ export interface InvitationTokenRepository {
|
||||
*/
|
||||
deleteExpired(): Promise<number>;
|
||||
|
||||
/**
|
||||
* Delete an invitation by id
|
||||
*/
|
||||
deleteById(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Update an invitation token
|
||||
*/
|
||||
|
||||
@ -4,69 +4,157 @@
|
||||
* Implements EmailPort using nodemailer
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import * as https from 'https';
|
||||
import { EmailPort, EmailOptions } from '@domain/ports/out/email.port';
|
||||
import { EmailTemplates } from './templates/email-templates';
|
||||
|
||||
// Display names included → moins susceptibles d'être marqués spam
|
||||
const EMAIL_SENDERS = {
|
||||
SECURITY: '"Xpeditis Sécurité" <security@xpeditis.com>',
|
||||
BOOKINGS: '"Xpeditis Bookings" <bookings@xpeditis.com>',
|
||||
TEAM: '"Équipe Xpeditis" <team@xpeditis.com>',
|
||||
CARRIERS: '"Xpeditis Transporteurs" <carriers@xpeditis.com>',
|
||||
NOREPLY: '"Xpeditis" <noreply@xpeditis.com>',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Génère une version plain text à partir du HTML pour améliorer la délivrabilité.
|
||||
* Les emails sans version texte sont pénalisés par les filtres anti-spam.
|
||||
*/
|
||||
function htmlToPlainText(html: string): string {
|
||||
return html
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/p>/gi, '\n\n')
|
||||
.replace(/<\/div>/gi, '\n')
|
||||
.replace(/<\/h[1-6]>/gi, '\n\n')
|
||||
.replace(/<a[^>]*href="([^"]*)"[^>]*>([^<]*)<\/a>/gi, '$2 ($1)')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmailAdapter implements EmailPort {
|
||||
export class EmailAdapter implements EmailPort, OnModuleInit {
|
||||
private readonly logger = new Logger(EmailAdapter.name);
|
||||
private transporter: nodemailer.Transporter;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly emailTemplates: EmailTemplates
|
||||
) {
|
||||
this.initializeTransporter();
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
const host = this.configService.get<string>('SMTP_HOST', 'localhost');
|
||||
|
||||
// 🔧 FIX: Mailtrap — IP directe hardcodée
|
||||
if (host.includes('mailtrap.io')) {
|
||||
this.buildTransporter('3.209.246.195', host);
|
||||
return;
|
||||
}
|
||||
|
||||
private initializeTransporter(): void {
|
||||
const host = this.configService.get<string>('SMTP_HOST', 'localhost');
|
||||
// 🔧 FIX: DNS over HTTPS — contourne le port 53 UDP (bloqué sur certains réseaux).
|
||||
// On appelle l'API DoH de Cloudflare via HTTPS (port 443) pour résoudre l'IP
|
||||
// AVANT de créer le transporter, puis on passe l'IP directement à nodemailer.
|
||||
if (!/^\d+\.\d+\.\d+\.\d+$/.test(host) && host !== 'localhost') {
|
||||
try {
|
||||
const ip = await this.resolveViaDoH(host);
|
||||
this.logger.log(`[DNS-DoH] ${host} → ${ip}`);
|
||||
this.buildTransporter(ip, host);
|
||||
return;
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[DNS-DoH] Failed to resolve ${host}: ${err.message} — using hostname directly`);
|
||||
}
|
||||
}
|
||||
|
||||
this.buildTransporter(host, host);
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout un hostname en IP via l'API DNS over HTTPS de Cloudflare.
|
||||
* Utilise HTTPS (port 443) donc fonctionne même quand le port 53 UDP est bloqué.
|
||||
*/
|
||||
private resolveViaDoH(hostname: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname)}&type=A`;
|
||||
const req = https.get(url, { headers: { Accept: 'application/dns-json' } }, (res) => {
|
||||
let raw = '';
|
||||
res.on('data', (chunk) => (raw += chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(raw);
|
||||
const aRecord = (json.Answer ?? []).find((r: any) => r.type === 1);
|
||||
if (aRecord?.data) {
|
||||
resolve(aRecord.data);
|
||||
} else {
|
||||
reject(new Error(`No A record returned by DoH for ${hostname}`));
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(10000, () => {
|
||||
req.destroy();
|
||||
reject(new Error('DoH request timed out'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private buildTransporter(actualHost: string, serverName: string): void {
|
||||
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');
|
||||
const secure = this.configService.get<boolean>('SMTP_SECURE', false);
|
||||
|
||||
// 🔧 FIX: Contournement DNS pour Mailtrap
|
||||
// Utilise automatiquement l'IP directe quand 'mailtrap.io' est détecté
|
||||
// Cela évite les timeouts DNS (queryA ETIMEOUT) sur certains réseaux
|
||||
const useDirectIP = host.includes('mailtrap.io');
|
||||
const actualHost = useDirectIP ? '3.209.246.195' : host;
|
||||
const serverName = useDirectIP ? 'smtp.mailtrap.io' : host; // Pour TLS
|
||||
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: actualHost,
|
||||
port,
|
||||
secure,
|
||||
auth: {
|
||||
user,
|
||||
pass,
|
||||
},
|
||||
// Configuration TLS avec servername pour IP directe
|
||||
auth: { user, pass },
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
servername: serverName, // ⚠️ CRITIQUE pour TLS avec IP directe
|
||||
servername: serverName,
|
||||
},
|
||||
// Timeouts optimisés
|
||||
connectionTimeout: 10000, // 10s
|
||||
greetingTimeout: 10000, // 10s
|
||||
socketTimeout: 30000, // 30s
|
||||
dnsTimeout: 10000, // 10s
|
||||
});
|
||||
connectionTimeout: 15000,
|
||||
greetingTimeout: 15000,
|
||||
socketTimeout: 30000,
|
||||
} as any);
|
||||
|
||||
this.logger.log(
|
||||
`Email adapter initialized with SMTP host: ${host}:${port} (secure: ${secure})` +
|
||||
(useDirectIP ? ` [Using direct IP: ${actualHost} with servername: ${serverName}]` : '')
|
||||
`Email transporter ready — ${serverName}:${port} (IP: ${actualHost}) user: ${user}`
|
||||
);
|
||||
|
||||
this.transporter.verify((error) => {
|
||||
if (error) {
|
||||
this.logger.error(`❌ SMTP connection FAILED: ${error.message}`);
|
||||
} else {
|
||||
this.logger.log(`✅ SMTP connection verified — ready to send emails`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async send(options: EmailOptions): Promise<void> {
|
||||
try {
|
||||
const from = this.configService.get<string>('SMTP_FROM', 'noreply@xpeditis.com');
|
||||
const from =
|
||||
options.from ??
|
||||
this.configService.get<string>('SMTP_FROM', EMAIL_SENDERS.NOREPLY);
|
||||
|
||||
await this.transporter.sendMail({
|
||||
// Génère automatiquement la version plain text si absente (améliore le score anti-spam)
|
||||
const text = options.text ?? (options.html ? htmlToPlainText(options.html) : undefined);
|
||||
|
||||
const info = await this.transporter.sendMail({
|
||||
from,
|
||||
to: options.to,
|
||||
cc: options.cc,
|
||||
@ -74,11 +162,13 @@ export class EmailAdapter implements EmailPort {
|
||||
replyTo: options.replyTo,
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
text: options.text,
|
||||
text,
|
||||
attachments: options.attachments,
|
||||
});
|
||||
|
||||
this.logger.log(`Email sent to ${options.to}: ${options.subject}`);
|
||||
this.logger.log(
|
||||
`✅ Email submitted — to: ${options.to} | from: ${from} | subject: "${options.subject}" | messageId: ${info.messageId} | accepted: ${JSON.stringify(info.accepted)} | rejected: ${JSON.stringify(info.rejected)}`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send email to ${options.to}`, error);
|
||||
throw error;
|
||||
@ -108,6 +198,7 @@ export class EmailAdapter implements EmailPort {
|
||||
|
||||
await this.send({
|
||||
to: email,
|
||||
from: EMAIL_SENDERS.BOOKINGS,
|
||||
subject: `Booking Confirmation - ${bookingNumber}`,
|
||||
html,
|
||||
attachments,
|
||||
@ -122,6 +213,7 @@ export class EmailAdapter implements EmailPort {
|
||||
|
||||
await this.send({
|
||||
to: email,
|
||||
from: EMAIL_SENDERS.SECURITY,
|
||||
subject: 'Verify your email - Xpeditis',
|
||||
html,
|
||||
});
|
||||
@ -135,6 +227,7 @@ export class EmailAdapter implements EmailPort {
|
||||
|
||||
await this.send({
|
||||
to: email,
|
||||
from: EMAIL_SENDERS.SECURITY,
|
||||
subject: 'Reset your password - Xpeditis',
|
||||
html,
|
||||
});
|
||||
@ -148,6 +241,7 @@ export class EmailAdapter implements EmailPort {
|
||||
|
||||
await this.send({
|
||||
to: email,
|
||||
from: EMAIL_SENDERS.NOREPLY,
|
||||
subject: 'Welcome to Xpeditis',
|
||||
html,
|
||||
});
|
||||
@ -169,6 +263,7 @@ export class EmailAdapter implements EmailPort {
|
||||
|
||||
await this.send({
|
||||
to: email,
|
||||
from: EMAIL_SENDERS.TEAM,
|
||||
subject: `You've been invited to join ${organizationName} on Xpeditis`,
|
||||
html,
|
||||
});
|
||||
@ -209,6 +304,7 @@ export class EmailAdapter implements EmailPort {
|
||||
|
||||
await this.send({
|
||||
to: email,
|
||||
from: EMAIL_SENDERS.TEAM,
|
||||
subject: `Invitation à rejoindre ${organizationName} sur Xpeditis`,
|
||||
html,
|
||||
});
|
||||
@ -273,6 +369,7 @@ export class EmailAdapter implements EmailPort {
|
||||
|
||||
await this.send({
|
||||
to: carrierEmail,
|
||||
from: EMAIL_SENDERS.BOOKINGS,
|
||||
subject: `Nouvelle demande de réservation ${bookingData.bookingNumber || ''} - ${bookingData.origin} → ${bookingData.destination}`,
|
||||
html,
|
||||
});
|
||||
@ -349,6 +446,7 @@ export class EmailAdapter implements EmailPort {
|
||||
|
||||
await this.send({
|
||||
to: email,
|
||||
from: EMAIL_SENDERS.CARRIERS,
|
||||
subject: '🚢 Votre compte transporteur Xpeditis a été créé',
|
||||
html,
|
||||
});
|
||||
@ -424,6 +522,7 @@ export class EmailAdapter implements EmailPort {
|
||||
|
||||
await this.send({
|
||||
to: email,
|
||||
from: EMAIL_SENDERS.SECURITY,
|
||||
subject: '🔑 Réinitialisation de votre mot de passe Xpeditis',
|
||||
html,
|
||||
});
|
||||
@ -535,6 +634,7 @@ export class EmailAdapter implements EmailPort {
|
||||
|
||||
await this.send({
|
||||
to: carrierEmail,
|
||||
from: EMAIL_SENDERS.BOOKINGS,
|
||||
subject: `Documents disponibles - Reservation ${data.bookingNumber || ''} ${data.origin} → ${data.destination}`,
|
||||
html,
|
||||
});
|
||||
@ -614,6 +714,7 @@ export class EmailAdapter implements EmailPort {
|
||||
|
||||
await this.send({
|
||||
to: carrierEmail,
|
||||
from: EMAIL_SENDERS.BOOKINGS,
|
||||
subject: `Nouveaux documents - Reservation ${data.origin} → ${data.destination}`,
|
||||
html,
|
||||
});
|
||||
|
||||
@ -78,6 +78,10 @@ export class TypeOrmInvitationTokenRepository implements InvitationTokenReposito
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
async deleteById(id: string): Promise<void> {
|
||||
await this.repository.delete({ id });
|
||||
}
|
||||
|
||||
async update(invitationToken: InvitationToken): Promise<InvitationToken> {
|
||||
const ormEntity = InvitationTokenOrmMapper.toOrm(invitationToken);
|
||||
const updated = await this.repository.save(ormEntity);
|
||||
|
||||
@ -10,11 +10,63 @@ import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { listUsers, updateUser, deleteUser, canInviteUser } from '@/lib/api';
|
||||
import { createInvitation } from '@/lib/api/invitations';
|
||||
import { createInvitation, listInvitations, cancelInvitation } from '@/lib/api/invitations';
|
||||
import { useAuth } from '@/lib/context/auth-context';
|
||||
import Link from 'next/link';
|
||||
import ExportButton from '@/components/ExportButton';
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
|
||||
function Pagination({
|
||||
page,
|
||||
total,
|
||||
onPage,
|
||||
}: {
|
||||
page: number;
|
||||
total: number;
|
||||
onPage: (p: number) => void;
|
||||
}) {
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="px-6 py-3 flex items-center justify-between border-t border-gray-200">
|
||||
<p className="text-sm text-gray-500">
|
||||
{Math.min((page - 1) * PAGE_SIZE + 1, total)}–{Math.min(page * PAGE_SIZE, total)} sur {total}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 rounded text-sm border border-gray-300 disabled:opacity-40 hover:bg-gray-50"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onPage(p)}
|
||||
className={`px-3 py-1 rounded text-sm border ${
|
||||
p === page
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => onPage(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1 rounded text-sm border border-gray-300 disabled:opacity-40 hover:bg-gray-50"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UsersManagementPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@ -22,11 +74,13 @@ export default function UsersManagementPage() {
|
||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
const [usersPage, setUsersPage] = useState(1);
|
||||
const [invitationsPage, setInvitationsPage] = useState(1);
|
||||
const [inviteForm, setInviteForm] = useState({
|
||||
email: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
role: 'USER' as 'MANAGER' | 'USER' | 'VIEWER',
|
||||
role: 'USER' as 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
@ -36,44 +90,37 @@ export default function UsersManagementPage() {
|
||||
queryFn: () => listUsers(),
|
||||
});
|
||||
|
||||
// Check license availability
|
||||
const { data: licenseStatus } = useQuery({
|
||||
queryKey: ['canInvite'],
|
||||
queryFn: () => canInviteUser(),
|
||||
});
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: (data: typeof inviteForm) => {
|
||||
return createInvitation({
|
||||
email: data.email,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
role: data.role,
|
||||
const { data: pendingInvitations } = useQuery({
|
||||
queryKey: ['invitations'],
|
||||
queryFn: () => listInvitations(),
|
||||
});
|
||||
},
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: (data: typeof inviteForm) => createInvitation(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||||
setSuccess('Invitation envoyée avec succès ! L\'utilisateur recevra un email avec un lien d\'inscription.');
|
||||
queryClient.invalidateQueries({ queryKey: ['invitations'] });
|
||||
setSuccess("Invitation envoyée avec succès ! L'utilisateur recevra un email avec un lien d'inscription.");
|
||||
setShowInviteModal(false);
|
||||
setInviteForm({
|
||||
email: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
role: 'USER',
|
||||
});
|
||||
setInviteForm({ email: '', firstName: '', lastName: '', role: 'USER' });
|
||||
setInvitationsPage(1);
|
||||
setTimeout(() => setSuccess(''), 5000);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.message || 'Échec de l\'envoi de l\'invitation');
|
||||
setError(err.response?.data?.message || "Échec de l'envoi de l'invitation");
|
||||
setTimeout(() => setError(''), 5000);
|
||||
},
|
||||
});
|
||||
|
||||
const changeRoleMutation = useMutation({
|
||||
mutationFn: ({ id, role }: { id: string; role: 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER' }) => {
|
||||
return updateUser(id, { role });
|
||||
},
|
||||
mutationFn: ({ id, role }: { id: string; role: 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER' }) =>
|
||||
updateUser(id, { role }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
setSuccess('Rôle mis à jour avec succès');
|
||||
@ -86,13 +133,12 @@ export default function UsersManagementPage() {
|
||||
});
|
||||
|
||||
const toggleActiveMutation = useMutation({
|
||||
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) => {
|
||||
return updateUser(id, { isActive: !isActive });
|
||||
},
|
||||
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
|
||||
updateUser(id, { isActive: !isActive }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||||
setSuccess('Statut de l\'utilisateur mis à jour avec succès');
|
||||
setSuccess("Statut de l'utilisateur mis à jour avec succès");
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
@ -110,19 +156,31 @@ export default function UsersManagementPage() {
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.message || 'Échec de la suppression de l\'utilisateur');
|
||||
setError(err.response?.data?.message || "Échec de la suppression de l'utilisateur");
|
||||
setTimeout(() => setError(''), 5000);
|
||||
},
|
||||
});
|
||||
|
||||
const cancelInvitationMutation = useMutation({
|
||||
mutationFn: (id: string) => cancelInvitation(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invitations'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||||
setSuccess('Invitation annulée avec succès');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.message || "Échec de l'annulation de l'invitation");
|
||||
setTimeout(() => setError(''), 5000);
|
||||
},
|
||||
});
|
||||
|
||||
// Restrict access to ADMIN and MANAGER only
|
||||
useEffect(() => {
|
||||
if (currentUser && currentUser.role !== 'ADMIN' && currentUser.role !== 'MANAGER') {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, [currentUser, router]);
|
||||
|
||||
// Don't render until we've checked permissions
|
||||
if (!currentUser || (currentUser.role !== 'ADMIN' && currentUser.role !== 'MANAGER')) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
@ -134,7 +192,6 @@ export default function UsersManagementPage() {
|
||||
const handleInvite = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
inviteMutation.mutate(inviteForm);
|
||||
};
|
||||
|
||||
@ -143,21 +200,23 @@ export default function UsersManagementPage() {
|
||||
};
|
||||
|
||||
const handleToggleActive = (userId: string, isActive: boolean) => {
|
||||
if (
|
||||
window.confirm(`Êtes-vous sûr de vouloir ${isActive ? 'désactiver' : 'activer'} cet utilisateur ?`)
|
||||
) {
|
||||
if (window.confirm(`Êtes-vous sûr de vouloir ${isActive ? 'désactiver' : 'activer'} cet utilisateur ?`)) {
|
||||
toggleActiveMutation.mutate({ id: userId, isActive });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (userId: string) => {
|
||||
if (
|
||||
window.confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.')
|
||||
) {
|
||||
if (window.confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.')) {
|
||||
deleteMutation.mutate(userId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelInvitation = (invId: string, name: string) => {
|
||||
if (window.confirm(`Annuler l'invitation envoyée à ${name} ?`)) {
|
||||
cancelInvitationMutation.mutate(invId);
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
ADMIN: 'bg-red-100 text-red-800',
|
||||
@ -168,6 +227,12 @@ export default function UsersManagementPage() {
|
||||
return colors[role] || 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const allUsers = users?.users || [];
|
||||
const pagedUsers = allUsers.slice((usersPage - 1) * PAGE_SIZE, usersPage * PAGE_SIZE);
|
||||
|
||||
const allPending = (pendingInvitations || []).filter(inv => !inv.isUsed);
|
||||
const pagedInvitations = allPending.slice((invitationsPage - 1) * PAGE_SIZE, invitationsPage * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* License Warning */}
|
||||
@ -186,10 +251,7 @@ export default function UsersManagementPage() {
|
||||
Mettez à niveau votre abonnement pour inviter plus d'utilisateurs.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Link
|
||||
href="/dashboard/settings/subscription"
|
||||
className="text-sm font-medium text-amber-800 hover:text-amber-900 underline"
|
||||
>
|
||||
<Link href="/dashboard/settings/subscription" className="text-sm font-medium text-amber-800 hover:text-amber-900 underline">
|
||||
Mettre à niveau l'abonnement
|
||||
</Link>
|
||||
</div>
|
||||
@ -210,10 +272,7 @@ export default function UsersManagementPage() {
|
||||
{licenseStatus.availableLicenses} licence{licenseStatus.availableLicenses !== 1 ? 's' : ''} restante{licenseStatus.availableLicenses !== 1 ? 's' : ''} ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses} utilisées)
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/settings/subscription"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<Link href="/dashboard/settings/subscription" className="text-sm font-medium text-blue-600 hover:text-blue-800">
|
||||
Gérer l'abonnement
|
||||
</Link>
|
||||
</div>
|
||||
@ -228,21 +287,13 @@ export default function UsersManagementPage() {
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<ExportButton
|
||||
data={users?.users || []}
|
||||
data={allUsers}
|
||||
filename="utilisateurs"
|
||||
columns={[
|
||||
{ key: 'firstName', label: 'Prénom' },
|
||||
{ key: 'lastName', label: 'Nom' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'role', label: 'Rôle', format: (v) => {
|
||||
const labels: Record<string, string> = {
|
||||
ADMIN: 'Administrateur',
|
||||
MANAGER: 'Manager',
|
||||
USER: 'Utilisateur',
|
||||
VIEWER: 'Lecteur',
|
||||
};
|
||||
return labels[v] || v;
|
||||
}},
|
||||
{ key: 'role', label: 'Rôle', format: (v) => ({ ADMIN: 'Administrateur', MANAGER: 'Manager', USER: 'Utilisateur', VIEWER: 'Lecteur' }[v] || v) },
|
||||
{ key: 'isActive', label: 'Statut', format: (v) => v ? 'Actif' : 'Inactif' },
|
||||
{ key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
|
||||
]}
|
||||
@ -281,52 +332,42 @@ export default function UsersManagementPage() {
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-medium text-gray-900">Utilisateurs</h2>
|
||||
{allUsers.length > 0 && (
|
||||
<p className="text-sm text-gray-500 mt-1">{allUsers.length} membre{allUsers.length !== 1 ? 's' : ''}</p>
|
||||
)}
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="px-6 py-12 text-center text-gray-500">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
Chargement des utilisateurs...
|
||||
</div>
|
||||
) : users?.users && users.users.length > 0 ? (
|
||||
) : pagedUsers.length > 0 ? (
|
||||
<>
|
||||
<div className="overflow-x-auto overflow-y-visible">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Utilisateur
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Rôle
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Statut
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date de création
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Utilisateur</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rôle</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date de création</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{users.users.map(user => (
|
||||
{pagedUsers.map(user => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10 bg-blue-600 rounded-full flex items-center justify-center text-white font-semibold">
|
||||
{user.firstName[0]}
|
||||
{user.lastName[0]}
|
||||
{user.firstName[0]}{user.lastName[0]}
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{user.firstName} {user.lastName}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{user.email}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900">{user.firstName} {user.lastName}</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@ -337,15 +378,12 @@ export default function UsersManagementPage() {
|
||||
<select
|
||||
value={user.role}
|
||||
onChange={e => handleRoleChange(user.id, e.target.value)}
|
||||
className={`text-xs font-semibold rounded-full px-3 py-1 ${getRoleBadgeColor(
|
||||
user.role
|
||||
)}`}
|
||||
className={`text-xs font-semibold rounded-full px-3 py-1 ${getRoleBadgeColor(user.role)}`}
|
||||
disabled={
|
||||
changeRoleMutation.isPending ||
|
||||
(user.role === 'ADMIN' && currentUser?.role !== 'ADMIN') ||
|
||||
user.id === currentUser?.id
|
||||
}
|
||||
title={user.id === currentUser?.id ? 'You cannot change your own role' : ''}
|
||||
>
|
||||
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Admin</option>}
|
||||
<option value="MANAGER">Manager</option>
|
||||
@ -354,18 +392,12 @@ export default function UsersManagementPage() {
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
user.isActive
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{user.isActive ? 'Actif' : 'Inactif'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
{new Date(user.createdAt).toLocaleDateString('fr-FR')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
@ -375,10 +407,7 @@ export default function UsersManagementPage() {
|
||||
setMenuPosition(null);
|
||||
} else {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setMenuPosition({
|
||||
top: rect.bottom + 5,
|
||||
left: rect.left - 180
|
||||
});
|
||||
setMenuPosition({ top: rect.bottom + 5, left: rect.left - 180 });
|
||||
setOpenMenuId(user.id);
|
||||
}
|
||||
}}
|
||||
@ -394,39 +423,25 @@ export default function UsersManagementPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination page={usersPage} total={allUsers.length} onPage={p => { setUsersPage(p); setOpenMenuId(null); }} />
|
||||
</>
|
||||
) : (
|
||||
<div className="px-6 py-12 text-center">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucun utilisateur</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Commencez par inviter un membre de l'équipe</p>
|
||||
<div className="mt-6">
|
||||
{licenseStatus?.canInvite ? (
|
||||
<button
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<button onClick={() => setShowInviteModal(true)} className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
|
||||
<span className="mr-2">+</span>
|
||||
Invite User
|
||||
Inviter un utilisateur
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/dashboard/settings/subscription"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700"
|
||||
>
|
||||
<Link href="/dashboard/settings/subscription" className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700">
|
||||
<span className="mr-2">+</span>
|
||||
Upgrade to Invite
|
||||
Mettre à niveau
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
@ -434,30 +449,94 @@ export default function UsersManagementPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pending Invitations */}
|
||||
{allPending.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-medium text-gray-900">Invitations en attente</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Utilisateurs invités mais n'ayant pas encore créé leur compte — {allPending.length} invitation{allPending.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Utilisateur</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rôle</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Expire le</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{pagedInvitations.map(inv => {
|
||||
const isExpired = new Date(inv.expiresAt) < new Date();
|
||||
return (
|
||||
<tr key={inv.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10 bg-gray-200 rounded-full flex items-center justify-center text-gray-500 font-semibold">
|
||||
{inv.firstName[0]}{inv.lastName[0]}
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">{inv.firstName} {inv.lastName}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{inv.email}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getRoleBadgeColor(inv.role)}`}>
|
||||
{inv.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(inv.expiresAt).toLocaleDateString('fr-FR')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${isExpired ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'}`}>
|
||||
{isExpired ? 'Expirée' : 'En attente'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<button
|
||||
onClick={() => handleCancelInvitation(inv.id, `${inv.firstName} ${inv.lastName}`)}
|
||||
disabled={cancelInvitationMutation.isPending}
|
||||
className="inline-flex items-center px-3 py-1 text-xs font-medium text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Annuler
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination page={invitationsPage} total={allPending.length} onPage={setInvitationsPage} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions Menu Modal */}
|
||||
{openMenuId && menuPosition && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[998]"
|
||||
onClick={() => {
|
||||
setOpenMenuId(null);
|
||||
setMenuPosition(null);
|
||||
}}
|
||||
onClick={() => { setOpenMenuId(null); setMenuPosition(null); }}
|
||||
/>
|
||||
<div
|
||||
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
|
||||
style={{
|
||||
top: `${menuPosition.top}px`,
|
||||
left: `${menuPosition.left}px`,
|
||||
}}
|
||||
style={{ top: `${menuPosition.top}px`, left: `${menuPosition.left}px` }}
|
||||
>
|
||||
<div className="py-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const user = users?.users.find(u => u.id === openMenuId);
|
||||
if (user) {
|
||||
handleToggleActive(user.id, user.isActive);
|
||||
}
|
||||
if (user) handleToggleActive(user.id, user.isActive);
|
||||
setOpenMenuId(null);
|
||||
setMenuPosition(null);
|
||||
}}
|
||||
@ -482,9 +561,7 @@ export default function UsersManagementPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (openMenuId) {
|
||||
handleDelete(openMenuId);
|
||||
}
|
||||
if (openMenuId) handleDelete(openMenuId);
|
||||
setOpenMenuId(null);
|
||||
setMenuPosition(null);
|
||||
}}
|
||||
@ -505,36 +582,21 @@ export default function UsersManagementPage() {
|
||||
{showInviteModal && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div
|
||||
className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75"
|
||||
onClick={() => setShowInviteModal(false)}
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" onClick={() => setShowInviteModal(false)} />
|
||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Inviter un utilisateur</h3>
|
||||
<button
|
||||
onClick={() => setShowInviteModal(false)}
|
||||
className="text-gray-400 hover:text-gray-500"
|
||||
>
|
||||
<button onClick={() => setShowInviteModal(false)} className="text-gray-400 hover:text-gray-500">
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleInvite} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Prénom *
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-700">Prénom *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
@ -554,7 +616,6 @@ export default function UsersManagementPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Adresse email *</label>
|
||||
<input
|
||||
@ -565,7 +626,6 @@ export default function UsersManagementPage() {
|
||||
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">Rôle *</label>
|
||||
<select
|
||||
@ -579,24 +639,21 @@ export default function UsersManagementPage() {
|
||||
<option value="VIEWER">Lecteur</option>
|
||||
</select>
|
||||
{currentUser?.role !== 'ADMIN' && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Seuls les administrateurs peuvent attribuer le rôle ADMIN
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">Seuls les administrateurs peuvent attribuer le rôle ADMIN</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={inviteMutation.isPending}
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm disabled:bg-gray-400"
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 sm:col-start-2 sm:text-sm disabled:bg-gray-400"
|
||||
>
|
||||
{inviteMutation.isPending ? 'Envoi en cours...' : 'Envoyer l\'invitation'}
|
||||
{inviteMutation.isPending ? 'Envoi en cours...' : "Envoyer l'invitation"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowInviteModal(false)}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm"
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 sm:mt-0 sm:col-start-1 sm:text-sm"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { get, post } from './client';
|
||||
import { get, post, del } from './client';
|
||||
|
||||
/**
|
||||
* Invitation API Types
|
||||
@ -49,3 +49,10 @@ export async function verifyInvitation(token: string): Promise<InvitationRespons
|
||||
export async function listInvitations(): Promise<InvitationResponse[]> {
|
||||
return get<InvitationResponse[]>('/api/v1/invitations');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel (delete) a pending invitation
|
||||
*/
|
||||
export async function cancelInvitation(id: string): Promise<void> {
|
||||
return del<void>(`/api/v1/invitations/${id}`);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user