fix email and documentation api

This commit is contained in:
David 2026-04-01 19:51:57 +02:00
parent ccc64b939a
commit 0e4c0d7785
14 changed files with 616 additions and 267 deletions

View File

@ -33,9 +33,10 @@ npm run frontend:dev # http://localhost:3000
```bash
# Backend (from apps/backend/)
npm test # Unit tests (Jest)
npm test -- booking.entity.spec.ts # Single file
npm run test:cov # With coverage
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).

View File

@ -37,12 +37,14 @@ MICROSOFT_CALLBACK_URL=http://localhost:4000/api/v1/auth/microsoft/callback
APP_URL=http://localhost:3000
# Email (SMTP)
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=apikey
SMTP_PASS=your-sendgrid-api-key
SMTP_FROM=noreply@xpeditis.com
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_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)
AWS_ACCESS_KEY_ID=your-aws-access-key

View File

@ -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: [

View File

@ -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 ====================
/**

View File

@ -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
*/

View File

@ -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',

View File

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

View File

@ -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)
*/

View File

@ -15,6 +15,7 @@ export interface EmailAttachment {
export interface EmailOptions {
to: string | string[];
from?: string;
cc?: string | string[];
bcc?: string | string[];
replyTo?: string;

View File

@ -35,6 +35,11 @@ export interface InvitationTokenRepository {
*/
deleteExpired(): Promise<number>;
/**
* Delete an invitation by id
*/
deleteById(id: string): Promise<void>;
/**
* Update an invitation token
*/

View File

@ -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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&nbsp;/g, ' ')
.replace(/&quot;/g, '"')
.replace(/&#39;/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;
}
// 🔧 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);
}
private initializeTransporter(): void {
const host = this.configService.get<string>('SMTP_HOST', 'localhost');
/**
* 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,
});

View File

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

View File

@ -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 { data: pendingInvitations } = useQuery({
queryKey: ['invitations'],
queryFn: () => listInvitations(),
});
const inviteMutation = useMutation({
mutationFn: (data: typeof inviteForm) => {
return createInvitation({
email: data.email,
firstName: data.firstName,
lastName: data.lastName,
role: data.role,
});
},
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,152 +332,116 @@ 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 ? (
<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>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.users.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]}
</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>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{user.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<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
)}`}
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>
<option value="USER">User</option>
<option value="VIEWER">Viewer</option>
</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'
}`}
>
{user.isActive ? 'Actif' : 'Inactif'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={(e) => {
if (openMenuId === user.id) {
setOpenMenuId(null);
setMenuPosition(null);
} else {
const rect = e.currentTarget.getBoundingClientRect();
setMenuPosition({
top: rect.bottom + 5,
left: rect.left - 180
});
setOpenMenuId(user.id);
}
}}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
</td>
) : 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>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{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]}
</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>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{user.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<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)}`}
disabled={
changeRoleMutation.isPending ||
(user.role === 'ADMIN' && currentUser?.role !== 'ADMIN') ||
user.id === currentUser?.id
}
>
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Admin</option>}
<option value="MANAGER">Manager</option>
<option value="USER">User</option>
<option value="VIEWER">Viewer</option>
</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'}`}>
{user.isActive ? 'Actif' : 'Inactif'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString('fr-FR')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={(e) => {
if (openMenuId === user.id) {
setOpenMenuId(null);
setMenuPosition(null);
} else {
const rect = e.currentTarget.getBoundingClientRect();
setMenuPosition({ top: rect.bottom + 5, left: rect.left - 180 });
setOpenMenuId(user.id);
}
}}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
</td>
</tr>
))}
</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>

View File

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