fix documentation et api key

This commit is contained in:
David 2026-03-31 16:19:35 +02:00
parent 6adcb2b9f8
commit ccc64b939a
21 changed files with 2931 additions and 9 deletions

View File

@ -93,24 +93,32 @@ Frontend env var: `NEXT_PUBLIC_API_URL` (defaults to `http://localhost:4000`)
```
apps/backend/src/
├── domain/ # CORE - Pure TypeScript, NO framework imports
│ ├── entities/ # Booking, RateQuote, Carrier, Port, Container, Notification, Webhook, AuditLog
│ ├── value-objects/ # Money, Email, BookingNumber, BookingStatus, PortCode, ContainerType, Volume, etc.
│ ├── entities/ # Booking, RateQuote, Carrier, Port, Container, Notification, Webhook,
│ │ # AuditLog, User, Organization, Subscription, License, CsvBooking,
│ │ # CsvRate, InvitationToken
│ ├── value-objects/ # Money, Email, BookingNumber, BookingStatus, PortCode, ContainerType,
│ │ # Volume, DateRange, Surcharge
│ ├── services/ # Pure domain services (csv-rate-price-calculator)
│ ├── ports/
│ │ ├── in/ # Use case interfaces with execute() method
│ │ └── out/ # Repository/SPI interfaces (token constants like BOOKING_REPOSITORY = 'BookingRepository')
│ └── exceptions/ # Domain-specific exceptions
├── application/ # Controllers, DTOs (class-validator), Guards, Decorators, Mappers
│ ├── [feature]/ # Feature modules grouped by domain (auth/, bookings/, rates/, etc.)
│ ├── [feature]/ # Feature modules: auth/, bookings/, csv-bookings, rates/, ports/,
│ │ # organizations/, users/, dashboard/, audit/, notifications/, webhooks/,
│ │ # gdpr/, admin/, subscriptions/
│ ├── controllers/ # REST controllers (also nested under feature folders)
│ ├── services/ # Application services (audit, notification, webhook, booking-automation, export, etc.)
│ ├── services/ # Application services: audit, notification, webhook,
│ │ # booking-automation, export, fuzzy-search, brute-force-protection
│ ├── gateways/ # WebSocket gateways (notifications.gateway.ts via Socket.IO)
│ ├── guards/ # JwtAuthGuard, RolesGuard, CustomThrottlerGuard
│ ├── decorators/ # @Public(), @Roles(), @CurrentUser()
│ ├── dto/ # Request/response DTOs with class-validator
│ ├── mappers/ # Domain ↔ DTO mappers
│ └── interceptors/ # PerformanceMonitoringInterceptor
└── infrastructure/ # TypeORM entities/repos/mappers, Redis cache, carrier APIs, MinIO/S3, email (MJML+Nodemailer), Stripe, Sentry
└── infrastructure/ # TypeORM entities/repos/mappers, Redis cache, carrier APIs,
# MinIO/S3, email (MJML+Nodemailer), Stripe, Sentry,
# Pappers (French SIRET registry), PDF generation
```
**Critical dependency rules**:
@ -218,12 +226,13 @@ Redis with 15-min TTL for rate quotes. Key format: `rate:{origin}:{destination}:
- JWT: access token 15min, refresh token 7d
- Password hashing: Argon2
- OAuth providers: Google, Microsoft (configured via passport strategies)
- Organizations can be validated via Pappers API (French SIRET/company registry) at `infrastructure/external/pappers-siret.adapter.ts`
### Carrier Portal Workflow
1. Admin creates CSV booking → assigns carrier
2. Email with magic link sent (1-hour expiry)
3. Carrier auto-login → accept/reject booking
4. Activity logged in `carrier_activities` table
4. Activity logged in `carrier_activities` table (via `CarrierProfile` + `CarrierActivity` ORM entities)
## Common Pitfalls

View File

@ -20,13 +20,14 @@ import { GDPRModule } from './application/gdpr/gdpr.module';
import { CsvBookingsModule } from './application/csv-bookings.module';
import { AdminModule } from './application/admin/admin.module';
import { SubscriptionsModule } from './application/subscriptions/subscriptions.module';
import { ApiKeysModule } from './application/api-keys/api-keys.module';
import { CacheModule } from './infrastructure/cache/cache.module';
import { CarrierModule } from './infrastructure/carriers/carrier.module';
import { SecurityModule } from './infrastructure/security/security.module';
import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module';
// Import global guards
import { JwtAuthGuard } from './application/guards/jwt-auth.guard';
import { ApiKeyOrJwtGuard } from './application/guards/api-key-or-jwt.guard';
import { CustomThrottlerGuard } from './application/guards/throttle.guard';
@Module({
@ -128,14 +129,15 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
GDPRModule,
AdminModule,
SubscriptionsModule,
ApiKeysModule,
],
controllers: [],
providers: [
// Global JWT authentication guard
// Global authentication guard — supports both JWT (frontend) and API key (Gold/Platinium)
// All routes are protected by default, use @Public() to bypass
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
useClass: ApiKeyOrJwtGuard,
},
// Global rate limiting guard
{

View File

@ -0,0 +1,81 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import { CurrentUser } from '../decorators/current-user.decorator';
import { RequiresFeature } from '../decorators/requires-feature.decorator';
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
import { ApiKeysService } from './api-keys.service';
import { CreateApiKeyDto, ApiKeyDto, CreateApiKeyResultDto } from '../dto/api-key.dto';
@ApiTags('API Keys')
@ApiBearerAuth()
@ApiSecurity('x-api-key')
@UseGuards(FeatureFlagGuard)
@RequiresFeature('api_access')
@Controller('api-keys')
export class ApiKeysController {
constructor(private readonly apiKeysService: ApiKeysService) {}
@Post()
@ApiOperation({
summary: 'Générer une nouvelle clé API',
description:
"Crée une clé API pour accès programmatique. La clé complète est retournée **une seule fois** — conservez-la immédiatement. Réservé aux abonnements Gold et Platinium.",
})
@ApiResponse({
status: 201,
description: 'Clé créée avec succès. La clé complète est dans le champ `fullKey`.',
type: CreateApiKeyResultDto,
})
@ApiResponse({ status: 403, description: 'Abonnement Gold ou Platinium requis' })
async create(
@CurrentUser() user: { id: string; organizationId: string },
@Body() dto: CreateApiKeyDto
): Promise<CreateApiKeyResultDto> {
return this.apiKeysService.generateApiKey(user.id, user.organizationId, dto);
}
@Get()
@ApiOperation({
summary: 'Lister les clés API',
description:
"Retourne toutes les clés API de l'organisation. Les clés complètes ne sont jamais exposées — uniquement le préfixe.",
})
@ApiResponse({ status: 200, type: [ApiKeyDto] })
async list(@CurrentUser() user: { organizationId: string }): Promise<ApiKeyDto[]> {
return this.apiKeysService.listApiKeys(user.organizationId);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: 'Révoquer une clé API',
description: 'Désactive immédiatement la clé API. Cette action est irréversible.',
})
@ApiResponse({ status: 204, description: 'Clé révoquée' })
@ApiResponse({ status: 404, description: 'Clé introuvable' })
async revoke(
@CurrentUser() user: { organizationId: string },
@Param('id', ParseUUIDPipe) keyId: string
): Promise<void> {
return this.apiKeysService.revokeApiKey(keyId, user.organizationId);
}
}

View File

@ -0,0 +1,45 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service';
// ORM Entities
import { ApiKeyOrmEntity } from '@infrastructure/persistence/typeorm/entities/api-key.orm-entity';
import { UserOrmEntity } from '@infrastructure/persistence/typeorm/entities/user.orm-entity';
// Repositories
import { TypeOrmApiKeyRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-api-key.repository';
import { TypeOrmUserRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
// Repository tokens
import { API_KEY_REPOSITORY } from '@domain/ports/out/api-key.repository';
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
// Subscriptions (provides SUBSCRIPTION_REPOSITORY)
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
// Feature flag guard needs SubscriptionRepository (injected via SubscriptionsModule)
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
@Module({
imports: [
TypeOrmModule.forFeature([ApiKeyOrmEntity, UserOrmEntity]),
SubscriptionsModule,
],
controllers: [ApiKeysController],
providers: [
ApiKeysService,
FeatureFlagGuard,
{
provide: API_KEY_REPOSITORY,
useClass: TypeOrmApiKeyRepository,
},
{
provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository,
},
],
exports: [ApiKeysService],
})
export class ApiKeysModule {}

View File

@ -0,0 +1,200 @@
/**
* ApiKeys Service
*
* Manages API key lifecycle:
* - Generation (GOLD/PLATINIUM subscribers only)
* - Listing (masked prefix only)
* - Revocation
* - Validation for inbound API key authentication
*/
import {
ForbiddenException,
Inject,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import { ApiKey } from '@domain/entities/api-key.entity';
import { ApiKeyRepository, API_KEY_REPOSITORY } from '@domain/ports/out/api-key.repository';
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
import {
SubscriptionRepository,
SUBSCRIPTION_REPOSITORY,
} from '@domain/ports/out/subscription.repository';
import { CreateApiKeyDto, ApiKeyDto, CreateApiKeyResultDto } from '../dto/api-key.dto';
/** Shape of request.user populated when an API key is used. */
export interface ApiKeyUserContext {
id: string;
email: string;
role: string;
organizationId: string;
firstName: string;
lastName: string;
plan: string;
planFeatures: string[];
}
const KEY_PREFIX_DISPLAY_LENGTH = 18; // "xped_live_" (10) + 8 hex chars
@Injectable()
export class ApiKeysService {
private readonly logger = new Logger(ApiKeysService.name);
constructor(
@Inject(API_KEY_REPOSITORY)
private readonly apiKeyRepository: ApiKeyRepository,
@Inject(USER_REPOSITORY)
private readonly userRepository: UserRepository,
@Inject(SUBSCRIPTION_REPOSITORY)
private readonly subscriptionRepository: SubscriptionRepository
) {}
/**
* Generate a new API key for the given user / organisation.
* The full raw key is returned exactly once it is never persisted.
*/
async generateApiKey(
userId: string,
organizationId: string,
dto: CreateApiKeyDto
): Promise<CreateApiKeyResultDto> {
await this.assertApiAccessPlan(organizationId);
const rawKey = this.buildRawKey();
const keyHash = this.hashKey(rawKey);
const keyPrefix = rawKey.substring(0, KEY_PREFIX_DISPLAY_LENGTH);
const apiKey = ApiKey.create({
id: uuidv4(),
organizationId,
userId,
name: dto.name,
keyHash,
keyPrefix,
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
});
const saved = await this.apiKeyRepository.save(apiKey);
this.logger.log(`API key created: ${saved.id} for org ${organizationId}`);
return {
id: saved.id,
name: saved.name,
keyPrefix: saved.keyPrefix,
isActive: saved.isActive,
lastUsedAt: saved.lastUsedAt,
expiresAt: saved.expiresAt,
createdAt: saved.createdAt,
fullKey: rawKey,
};
}
/**
* List all API keys for an organisation. Never exposes key hashes.
*/
async listApiKeys(organizationId: string): Promise<ApiKeyDto[]> {
const keys = await this.apiKeyRepository.findByOrganizationId(organizationId);
return keys.map(k => this.toDto(k));
}
/**
* Revoke (deactivate) an API key.
*/
async revokeApiKey(keyId: string, organizationId: string): Promise<void> {
const key = await this.apiKeyRepository.findById(keyId);
if (!key || key.organizationId !== organizationId) {
throw new NotFoundException('Clé API introuvable');
}
const revoked = key.revoke();
await this.apiKeyRepository.save(revoked);
this.logger.log(`API key revoked: ${keyId} for org ${organizationId}`);
}
/**
* Validate an inbound raw API key and return the user context.
* Returns null if the key is invalid, expired, or the plan is insufficient.
* Also asynchronously updates lastUsedAt.
*/
async validateAndGetUser(rawKey: string): Promise<ApiKeyUserContext | null> {
if (!rawKey?.startsWith('xped_live_')) return null;
const keyHash = this.hashKey(rawKey);
const apiKey = await this.apiKeyRepository.findByKeyHash(keyHash);
if (!apiKey || !apiKey.isValid()) return null;
// Real-time plan check — in case the org downgraded after key creation
const subscription = await this.subscriptionRepository.findByOrganizationId(
apiKey.organizationId
);
if (!subscription || !subscription.hasFeature('api_access')) {
this.logger.warn(
`API key used but org ${apiKey.organizationId} no longer has api_access feature`
);
return null;
}
// Update lastUsedAt asynchronously — don't block the request
this.apiKeyRepository
.save(apiKey.recordUsage())
.catch(err => this.logger.warn(`Failed to update lastUsedAt for key ${apiKey.id}: ${err}`));
const user = await this.userRepository.findById(apiKey.userId);
if (!user || !user.isActive) return null;
return {
id: user.id,
email: user.email,
role: user.role,
organizationId: user.organizationId,
firstName: user.firstName,
lastName: user.lastName,
plan: subscription.plan.value,
planFeatures: [...subscription.plan.planFeatures],
};
}
// ── Helpers ─────────────────────────────────────────────────────────────
private async assertApiAccessPlan(organizationId: string): Promise<void> {
const subscription = await this.subscriptionRepository.findByOrganizationId(organizationId);
if (!subscription || !subscription.hasFeature('api_access')) {
throw new ForbiddenException(
"L'accès API nécessite un abonnement Gold ou Platinium. Mettez à niveau votre abonnement pour générer des clés API."
);
}
}
/** Format: xped_live_<64 random hex chars> */
private buildRawKey(): string {
return `xped_live_${crypto.randomBytes(32).toString('hex')}`;
}
private hashKey(rawKey: string): string {
return crypto.createHash('sha256').update(rawKey).digest('hex');
}
private toDto(apiKey: ApiKey): ApiKeyDto {
return {
id: apiKey.id,
name: apiKey.name,
keyPrefix: apiKey.keyPrefix,
isActive: apiKey.isActive,
lastUsedAt: apiKey.lastUsedAt,
expiresAt: apiKey.expiresAt,
createdAt: apiKey.createdAt,
};
}
}

View File

@ -0,0 +1,63 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
export class CreateApiKeyDto {
@ApiProperty({
description: 'Nom de la clé API (pour identification)',
example: 'Intégration ERP Production',
maxLength: 100,
})
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
@ApiPropertyOptional({
description: "Date d'expiration de la clé (ISO 8601). Si absent, la clé n'expire pas.",
example: '2027-01-01T00:00:00.000Z',
})
@IsOptional()
@IsDateString()
expiresAt?: string;
}
export class ApiKeyDto {
@ApiProperty({ description: 'Identifiant unique de la clé', example: 'uuid-here' })
id: string;
@ApiProperty({ description: 'Nom de la clé', example: 'Intégration ERP Production' })
name: string;
@ApiProperty({
description: 'Préfixe de la clé (pour identification visuelle)',
example: 'xped_live_a1b2c3d4',
})
keyPrefix: string;
@ApiProperty({ description: 'La clé est-elle active', example: true })
isActive: boolean;
@ApiPropertyOptional({
description: 'Dernière utilisation de la clé',
example: '2025-03-20T14:30:00.000Z',
})
lastUsedAt: Date | null;
@ApiPropertyOptional({
description: "Date d'expiration",
example: '2027-01-01T00:00:00.000Z',
})
expiresAt: Date | null;
@ApiProperty({ description: 'Date de création', example: '2025-03-26T10:00:00.000Z' })
createdAt: Date;
}
export class CreateApiKeyResultDto extends ApiKeyDto {
@ApiProperty({
description:
'Clé API complète — affichée UNE SEULE FOIS. Conservez-la en lieu sûr, elle ne sera plus visible.',
example: 'xped_live_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
})
fullKey: string;
}

View File

@ -0,0 +1,55 @@
import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ApiKeysService } from '../api-keys/api-keys.service';
import { JwtAuthGuard } from './jwt-auth.guard';
/**
* Combined Authentication Guard
*
* Replaces the global JwtAuthGuard to support two authentication methods:
*
* 1. **API Key** (`X-API-Key` header)
* - Validates the raw key against its stored SHA-256 hash
* - Checks the organisation subscription is GOLD or PLATINIUM in real-time
* - Sets request.user with full user/plan context
* - Available exclusively to Gold and Platinium subscribers
*
* 2. **JWT Bearer token** (`Authorization: Bearer <token>`)
* - Delegates to the existing Passport JWT strategy (unchanged behaviour)
* - Works for all subscription tiers (frontend access)
*
* Routes decorated with @Public() bypass both methods.
*
* Priority: API Key is checked first; if absent, falls back to JWT.
*/
@Injectable()
export class ApiKeyOrJwtGuard extends JwtAuthGuard {
constructor(
reflector: Reflector,
private readonly apiKeysService: ApiKeysService
) {
super(reflector);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Record<string, any>>();
const rawApiKey: string | undefined = request.headers['x-api-key'];
if (rawApiKey) {
const userContext = await this.apiKeysService.validateAndGetUser(rawApiKey);
if (!userContext) {
throw new UnauthorizedException(
"Clé API invalide, expirée ou votre abonnement ne permet plus l'accès API."
);
}
request.user = userContext;
return true;
}
// No API key header — use standard JWT flow (handles @Public() too)
return super.canActivate(context) as Promise<boolean>;
}
}

View File

@ -1,2 +1,3 @@
export * from './jwt-auth.guard';
export * from './roles.guard';
export * from './api-key-or-jwt.guard';

View File

@ -0,0 +1,135 @@
/**
* ApiKey Entity
*
* Represents a programmatic API key for an organization.
* Only GOLD and PLATINIUM subscribers can create and use API keys.
*
* Security model:
* - The raw key is NEVER persisted only its SHA-256 hash is stored.
* - The full key is returned exactly once, at creation time.
* - The keyPrefix (first 16 chars) is stored for display purposes.
*/
export interface ApiKeyProps {
readonly id: string;
readonly organizationId: string;
readonly userId: string;
readonly name: string;
readonly keyHash: string;
readonly keyPrefix: string;
readonly isActive: boolean;
readonly lastUsedAt: Date | null;
readonly expiresAt: Date | null;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export class ApiKey {
private readonly props: ApiKeyProps;
private constructor(props: ApiKeyProps) {
this.props = props;
}
static create(params: {
id: string;
organizationId: string;
userId: string;
name: string;
keyHash: string;
keyPrefix: string;
expiresAt?: Date | null;
}): ApiKey {
const now = new Date();
return new ApiKey({
id: params.id,
organizationId: params.organizationId,
userId: params.userId,
name: params.name,
keyHash: params.keyHash,
keyPrefix: params.keyPrefix,
isActive: true,
lastUsedAt: null,
expiresAt: params.expiresAt ?? null,
createdAt: now,
updatedAt: now,
});
}
static fromPersistence(props: ApiKeyProps): ApiKey {
return new ApiKey(props);
}
get id(): string {
return this.props.id;
}
get organizationId(): string {
return this.props.organizationId;
}
get userId(): string {
return this.props.userId;
}
get name(): string {
return this.props.name;
}
get keyHash(): string {
return this.props.keyHash;
}
get keyPrefix(): string {
return this.props.keyPrefix;
}
get isActive(): boolean {
return this.props.isActive;
}
get lastUsedAt(): Date | null {
return this.props.lastUsedAt;
}
get expiresAt(): Date | null {
return this.props.expiresAt;
}
get createdAt(): Date {
return this.props.createdAt;
}
get updatedAt(): Date {
return this.props.updatedAt;
}
isExpired(): boolean {
if (!this.props.expiresAt) return false;
return this.props.expiresAt < new Date();
}
isValid(): boolean {
return this.props.isActive && !this.isExpired();
}
revoke(): ApiKey {
return new ApiKey({
...this.props,
isActive: false,
updatedAt: new Date(),
});
}
recordUsage(): ApiKey {
return new ApiKey({
...this.props,
lastUsedAt: new Date(),
updatedAt: new Date(),
});
}
toObject(): ApiKeyProps {
return { ...this.props };
}
}

View File

@ -0,0 +1,11 @@
import { ApiKey } from '@domain/entities/api-key.entity';
export const API_KEY_REPOSITORY = 'API_KEY_REPOSITORY';
export interface ApiKeyRepository {
save(apiKey: ApiKey): Promise<ApiKey>;
findById(id: string): Promise<ApiKey | null>;
findByKeyHash(keyHash: string): Promise<ApiKey | null>;
findByOrganizationId(organizationId: string): Promise<ApiKey[]>;
delete(id: string): Promise<void>;
}

View File

@ -0,0 +1,59 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
ManyToOne,
JoinColumn,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { OrganizationOrmEntity } from './organization.orm-entity';
import { UserOrmEntity } from './user.orm-entity';
@Entity('api_keys')
@Index('idx_api_keys_organization_id', ['organizationId'])
@Index('idx_api_keys_user_id', ['userId'])
@Index('idx_api_keys_is_active', ['isActive'])
export class ApiKeyOrmEntity {
@PrimaryColumn('uuid')
id: string;
@Column({ name: 'organization_id', type: 'uuid' })
organizationId: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ length: 100 })
name: string;
@Column({ name: 'key_hash', length: 64, unique: true })
keyHash: string;
@Column({ name: 'key_prefix', length: 20 })
keyPrefix: string;
@Column({ name: 'is_active', default: true })
isActive: boolean;
@Column({ name: 'last_used_at', type: 'timestamp', nullable: true })
lastUsedAt: Date | null;
@Column({ name: 'expires_at', type: 'timestamp', nullable: true })
expiresAt: Date | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@ManyToOne(() => OrganizationOrmEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'organization_id' })
organization: OrganizationOrmEntity;
@ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: UserOrmEntity;
}

View File

@ -0,0 +1,40 @@
import { ApiKey } from '@domain/entities/api-key.entity';
import { ApiKeyOrmEntity } from '../entities/api-key.orm-entity';
export class ApiKeyOrmMapper {
static toDomain(orm: ApiKeyOrmEntity): ApiKey {
return ApiKey.fromPersistence({
id: orm.id,
organizationId: orm.organizationId,
userId: orm.userId,
name: orm.name,
keyHash: orm.keyHash,
keyPrefix: orm.keyPrefix,
isActive: orm.isActive,
lastUsedAt: orm.lastUsedAt,
expiresAt: orm.expiresAt,
createdAt: orm.createdAt,
updatedAt: orm.updatedAt,
});
}
static toOrm(domain: ApiKey): ApiKeyOrmEntity {
const orm = new ApiKeyOrmEntity();
orm.id = domain.id;
orm.organizationId = domain.organizationId;
orm.userId = domain.userId;
orm.name = domain.name;
orm.keyHash = domain.keyHash;
orm.keyPrefix = domain.keyPrefix;
orm.isActive = domain.isActive;
orm.lastUsedAt = domain.lastUsedAt;
orm.expiresAt = domain.expiresAt;
orm.createdAt = domain.createdAt;
orm.updatedAt = domain.updatedAt;
return orm;
}
static toDomainMany(orms: ApiKeyOrmEntity[]): ApiKey[] {
return orms.map(orm => ApiKeyOrmMapper.toDomain(orm));
}
}

View File

@ -0,0 +1,62 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Migration: Create API Keys Table
*
* Stores API keys for programmatic access.
* Only GOLD and PLATINIUM subscribers can create keys (enforced at application level).
*
* Security: the raw key is NEVER stored only its SHA-256 hex hash.
*/
export class CreateApiKeysTable1741000000001 implements MigrationInterface {
name = 'CreateApiKeysTable1741000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "api_keys" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"organization_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"name" VARCHAR(100) NOT NULL,
"key_hash" VARCHAR(64) NOT NULL,
"key_prefix" VARCHAR(20) NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT TRUE,
"last_used_at" TIMESTAMP NULL,
"expires_at" TIMESTAMP NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT NOW(),
"updated_at" TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT "pk_api_keys" PRIMARY KEY ("id"),
CONSTRAINT "uq_api_keys_key_hash" UNIQUE ("key_hash"),
CONSTRAINT "fk_api_keys_organization" FOREIGN KEY ("organization_id")
REFERENCES "organizations"("id") ON DELETE CASCADE,
CONSTRAINT "fk_api_keys_user" FOREIGN KEY ("user_id")
REFERENCES "users"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(
`CREATE INDEX "idx_api_keys_organization_id" ON "api_keys" ("organization_id")`
);
await queryRunner.query(
`CREATE INDEX "idx_api_keys_user_id" ON "api_keys" ("user_id")`
);
await queryRunner.query(
`CREATE INDEX "idx_api_keys_is_active" ON "api_keys" ("is_active")`
);
await queryRunner.query(
`CREATE INDEX "idx_api_keys_key_hash" ON "api_keys" ("key_hash")`
);
await queryRunner.query(
`COMMENT ON TABLE "api_keys" IS 'API keys for programmatic access — GOLD and PLATINIUM plans only'`
);
await queryRunner.query(
`COMMENT ON COLUMN "api_keys"."key_hash" IS 'SHA-256 hex hash of the raw API key. The raw key is never stored.'`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "api_keys" CASCADE`);
}
}

View File

@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ApiKey } from '@domain/entities/api-key.entity';
import { ApiKeyRepository } from '@domain/ports/out/api-key.repository';
import { ApiKeyOrmEntity } from '../entities/api-key.orm-entity';
import { ApiKeyOrmMapper } from '../mappers/api-key-orm.mapper';
@Injectable()
export class TypeOrmApiKeyRepository implements ApiKeyRepository {
constructor(
@InjectRepository(ApiKeyOrmEntity)
private readonly repo: Repository<ApiKeyOrmEntity>
) {}
async save(apiKey: ApiKey): Promise<ApiKey> {
const orm = ApiKeyOrmMapper.toOrm(apiKey);
const saved = await this.repo.save(orm);
return ApiKeyOrmMapper.toDomain(saved);
}
async findById(id: string): Promise<ApiKey | null> {
const orm = await this.repo.findOne({ where: { id } });
return orm ? ApiKeyOrmMapper.toDomain(orm) : null;
}
async findByKeyHash(keyHash: string): Promise<ApiKey | null> {
const orm = await this.repo.findOne({ where: { keyHash } });
return orm ? ApiKeyOrmMapper.toDomain(orm) : null;
}
async findByOrganizationId(organizationId: string): Promise<ApiKey[]> {
const orms = await this.repo.find({
where: { organizationId },
order: { createdAt: 'DESC' },
});
return ApiKeyOrmMapper.toDomainMany(orms);
}
async delete(id: string): Promise<void> {
await this.repo.delete({ id });
}
}

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,8 @@ import {
Users,
LogOut,
Lock,
Code2,
Key,
} from 'lucide-react';
import { useSubscription } from '@/lib/context/subscription-context';
import StatusBadge from '@/components/ui/StatusBadge';
@ -60,6 +62,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
{ name: 'Suivi', href: '/dashboard/track-trace', icon: Search, requiredFeature: 'dashboard' },
{ name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' },
{ name: 'Organisation', href: '/dashboard/settings/organization', icon: Building2 },
{ name: 'Documentation API', href: '/dashboard/docs', icon: Code2 },
{ name: 'Clés API', href: '/dashboard/settings/api-keys', icon: Key, requiredFeature: 'api_access' as PlanFeature },
// ADMIN and MANAGER only navigation items
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [
{ name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users, requiredFeature: 'user_management' as PlanFeature },

View File

@ -0,0 +1,489 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { listApiKeys, createApiKey, revokeApiKey } from '@/lib/api/api-keys';
import type { ApiKeyDto, CreateApiKeyResultDto } from '@/lib/api/api-keys';
import { useSubscription } from '@/lib/context/subscription-context';
import {
Key,
Plus,
Trash2,
Copy,
Check,
AlertTriangle,
Clock,
X,
ShieldCheck,
Lock,
} from 'lucide-react';
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatDate(iso: string | null): string {
if (!iso) return '—';
return new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(iso));
}
function keyStatusBadge(key: ApiKeyDto) {
if (!key.isActive) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
Révoquée
</span>
);
}
if (key.expiresAt && new Date(key.expiresAt) < new Date()) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
<Clock className="w-3 h-3" />
Expirée
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
Active
</span>
);
}
// ─── Copy button ─────────────────────────────────────────────────────────────
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
onClick={handleCopy}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors"
>
{copied ? (
<>
<Check className="w-3.5 h-3.5 text-green-500" />
<span className="text-green-600">Copié</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5 text-gray-400" />
<span className="text-gray-600">Copier</span>
</>
)}
</button>
);
}
// ─── Creation success modal ──────────────────────────────────────────────────
function CreatedKeyModal({
result,
onClose,
}: {
result: CreateApiKeyResultDto;
onClose: () => void;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center">
<ShieldCheck className="w-5 h-5 text-green-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">Clé API créée</h2>
<p className="text-sm text-gray-500">{result.name}</p>
</div>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Warning */}
<div className="mx-6 mt-6 p-4 bg-amber-50 border border-amber-200 rounded-xl flex gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-800">
<strong>Copiez cette clé maintenant.</strong> Elle ne sera plus jamais affichée après
la fermeture de cette fenêtre.
</p>
</div>
{/* Key */}
<div className="p-6">
<label className="block text-xs font-medium text-gray-500 mb-2 uppercase tracking-wide">
Clé API complète
</label>
<div className="flex items-center gap-2 p-3 bg-gray-950 rounded-xl border border-gray-800">
<code className="flex-1 text-xs font-mono text-green-400 break-all">
{result.fullKey}
</code>
<CopyButton text={result.fullKey} />
</div>
<p className="mt-3 text-xs text-gray-500">
Stockez-la dans vos variables d&apos;environnement ou un gestionnaire de secrets.
</p>
</div>
{/* Footer */}
<div className="p-6 pt-0">
<button
onClick={onClose}
className="w-full py-2.5 bg-[#10183A] hover:bg-[#1a2550] text-white text-sm font-medium rounded-xl transition-colors"
>
J&apos;ai copié ma clé, fermer
</button>
</div>
</div>
</div>
);
}
// ─── Create key form modal ───────────────────────────────────────────────────
function CreateKeyModal({
onSuccess,
onClose,
}: {
onSuccess: (result: CreateApiKeyResultDto) => void;
onClose: () => void;
}) {
const [name, setName] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createApiKey,
onSuccess: result => {
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
onSuccess(result);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate({
name: name.trim(),
...(expiresAt ? { expiresAt: new Date(expiresAt).toISOString() } : {}),
});
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center">
<Key className="w-5 h-5 text-blue-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900">Nouvelle clé API</h2>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Nom de la clé <span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="ex: Intégration ERP Production"
maxLength={100}
required
className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#34CCCD] focus:border-transparent"
/>
<p className="mt-1 text-xs text-gray-400">{name.length}/100 caractères</p>
</div>
{/* Expiry */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Date d&apos;expiration{' '}
<span className="text-gray-400 font-normal">(optionnel)</span>
</label>
<input
type="date"
value={expiresAt}
onChange={e => setExpiresAt(e.target.value)}
min={new Date().toISOString().split('T')[0]}
className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#34CCCD] focus:border-transparent"
/>
<p className="mt-1 text-xs text-gray-400">
Si vide, la clé n&apos;expire jamais.
</p>
</div>
{/* Error */}
{mutation.isError && (
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
Une erreur est survenue. Veuillez réessayer.
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 py-2.5 border border-gray-200 text-gray-700 text-sm font-medium rounded-xl hover:bg-gray-50 transition-colors"
>
Annuler
</button>
<button
type="submit"
disabled={!name.trim() || mutation.isPending}
className="flex-1 py-2.5 bg-[#10183A] hover:bg-[#1a2550] disabled:opacity-50 text-white text-sm font-medium rounded-xl transition-colors"
>
{mutation.isPending ? 'Création...' : 'Créer la clé'}
</button>
</div>
</form>
</div>
</div>
);
}
// ─── Revoke confirm modal ────────────────────────────────────────────────────
function RevokeConfirmModal({
apiKey,
onConfirm,
onClose,
}: {
apiKey: ApiKeyDto;
onConfirm: () => void;
onClose: () => void;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md">
<div className="p-6">
<div className="w-12 h-12 rounded-xl bg-red-100 flex items-center justify-center mx-auto mb-4">
<Trash2 className="w-6 h-6 text-red-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900 text-center mb-2">
Révoquer cette clé ?
</h2>
<p className="text-sm text-gray-600 text-center mb-1">
<strong className="text-gray-900">{apiKey.name}</strong>
</p>
<p className="text-sm text-gray-500 text-center">
Cette action est <strong>immédiate et irréversible</strong>. Toute requête utilisant
cette clé sera refusée.
</p>
</div>
<div className="px-6 pb-6 flex gap-3">
<button
onClick={onClose}
className="flex-1 py-2.5 border border-gray-200 text-gray-700 text-sm font-medium rounded-xl hover:bg-gray-50 transition-colors"
>
Annuler
</button>
<button
onClick={onConfirm}
className="flex-1 py-2.5 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-xl transition-colors"
>
Révoquer
</button>
</div>
</div>
</div>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function ApiKeysPage() {
const { hasFeature } = useSubscription();
const queryClient = useQueryClient();
const hasApiAccess = hasFeature('api_access');
const [showCreateModal, setShowCreateModal] = useState(false);
const [createdKey, setCreatedKey] = useState<CreateApiKeyResultDto | null>(null);
const [revokeTarget, setRevokeTarget] = useState<ApiKeyDto | null>(null);
const { data: apiKeys, isLoading } = useQuery({
queryKey: ['api-keys'],
queryFn: listApiKeys,
enabled: hasApiAccess,
});
const revokeMutation = useMutation({
mutationFn: revokeApiKey,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
setRevokeTarget(null);
},
});
// Plan upsell screen
if (!hasApiAccess) {
return (
<div className="max-w-lg mx-auto mt-16 text-center">
<div className="w-16 h-16 rounded-2xl bg-gray-100 flex items-center justify-center mx-auto mb-6">
<Lock className="w-8 h-8 text-gray-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-3">Accès API</h1>
<p className="text-gray-600 mb-8">
L&apos;accès programmatique à l&apos;API Xpeditis est disponible sur les plans{' '}
<strong>Gold</strong> et <strong>Platinium</strong> uniquement.
</p>
<a
href="/pricing"
className="inline-flex items-center gap-2 px-6 py-3 bg-[#10183A] hover:bg-[#1a2550] text-white text-sm font-medium rounded-xl transition-colors"
>
Voir les plans
</a>
</div>
);
}
const activeKeys = apiKeys?.filter(k => k.isActive) ?? [];
return (
<>
{/* Modals */}
{showCreateModal && (
<CreateKeyModal
onSuccess={result => {
setShowCreateModal(false);
setCreatedKey(result);
}}
onClose={() => setShowCreateModal(false)}
/>
)}
{createdKey && (
<CreatedKeyModal result={createdKey} onClose={() => setCreatedKey(null)} />
)}
{revokeTarget && (
<RevokeConfirmModal
apiKey={revokeTarget}
onConfirm={() => revokeMutation.mutate(revokeTarget.id)}
onClose={() => setRevokeTarget(null)}
/>
)}
{/* Page header */}
<div className="flex items-start justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Clés API</h1>
<p className="mt-1 text-sm text-gray-500">
Gérez les clés d&apos;accès programmatique à l&apos;API Xpeditis.
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
disabled={activeKeys.length >= 20}
className="flex items-center gap-2 px-4 py-2.5 bg-[#10183A] hover:bg-[#1a2550] disabled:opacity-50 text-white text-sm font-medium rounded-xl transition-colors"
>
<Plus className="w-4 h-4" />
Nouvelle clé
</button>
</div>
{/* Info banner */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-100 rounded-xl flex gap-3">
<ShieldCheck className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-medium mb-0.5">Comment utiliser vos clés API</p>
<p>
Ajoutez l&apos;en-tête{' '}
<code className="px-1.5 py-0.5 bg-blue-100 rounded text-blue-900 font-mono text-xs">
X-API-Key: xped_live_...
</code>{' '}
à chaque requête HTTP.{' '}
<a
href="/dashboard/docs?section=authentication"
className="font-medium underline underline-offset-2"
>
Voir la documentation
</a>
</p>
</div>
</div>
{/* Keys list */}
<div className="bg-white border border-gray-200 rounded-2xl overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-4 border-[#34CCCD] border-t-transparent rounded-full animate-spin" />
</div>
) : !apiKeys || apiKeys.length === 0 ? (
<div className="py-16 text-center">
<Key className="w-10 h-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 text-sm">Aucune clé API pour le moment.</p>
<button
onClick={() => setShowCreateModal(true)}
className="mt-4 text-sm font-medium text-[#10183A] hover:underline"
>
Créer votre première clé
</button>
</div>
) : (
<div className="divide-y divide-gray-100">
{/* Table header */}
<div className="grid grid-cols-[2fr_1.5fr_1fr_1fr_auto] gap-4 px-6 py-3 bg-gray-50 text-xs font-medium text-gray-500 uppercase tracking-wide">
<span>Nom / Préfixe</span>
<span>Dernière utilisation</span>
<span>Expiration</span>
<span>Statut</span>
<span />
</div>
{apiKeys.map(key => (
<div
key={key.id}
className="grid grid-cols-[2fr_1.5fr_1fr_1fr_auto] gap-4 items-center px-6 py-4"
>
{/* Name + prefix */}
<div>
<p className="text-sm font-medium text-gray-900">{key.name}</p>
<code className="text-xs font-mono text-gray-400">{key.keyPrefix}</code>
</div>
{/* Last used */}
<span className="text-sm text-gray-600">{formatDate(key.lastUsedAt)}</span>
{/* Expiry */}
<span className="text-sm text-gray-600">{formatDate(key.expiresAt)}</span>
{/* Status */}
<div>{keyStatusBadge(key)}</div>
{/* Actions */}
<button
onClick={() => setRevokeTarget(key)}
disabled={!key.isActive || revokeMutation.isPending}
title="Révoquer cette clé"
className="p-2 text-gray-400 hover:text-red-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
{/* Quota */}
{apiKeys && apiKeys.length > 0 && (
<p className="mt-4 text-xs text-gray-400 text-right">
{activeKeys.length} / 20 clés actives utilisées
</p>
)}
</>
);
}

View File

@ -0,0 +1,60 @@
'use client';
import { useState } from 'react';
import { Check, Copy } from 'lucide-react';
interface CodeBlockProps {
code: string;
language?: string;
filename?: string;
}
export function CodeBlock({ code, language = 'bash', filename }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(code.trim());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="rounded-xl overflow-hidden border border-gray-800 my-4">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-[#161b22] border-b border-gray-800">
<div className="flex items-center gap-2">
{filename && (
<span className="text-xs text-gray-400 font-mono">{filename}</span>
)}
{!filename && (
<span className="text-xs font-mono px-2 py-0.5 rounded bg-gray-700 text-gray-300">
{language}
</span>
)}
</div>
<button
onClick={handleCopy}
className="flex items-center gap-1.5 text-xs text-gray-400 hover:text-gray-200 transition-colors px-2 py-1 rounded hover:bg-gray-700"
>
{copied ? (
<>
<Check className="w-3.5 h-3.5 text-green-400" />
<span className="text-green-400">Copié</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5" />
<span>Copier</span>
</>
)}
</button>
</div>
{/* Code */}
<div className="bg-[#0d1117] overflow-x-auto">
<pre className="p-4 text-sm font-mono text-gray-200 leading-relaxed whitespace-pre">
{code.trim()}
</pre>
</div>
</div>
);
}

View File

@ -0,0 +1,61 @@
import {
Home,
Zap,
Key,
Package,
TrendingUp,
Building2,
List,
AlertTriangle,
ShieldCheck,
type LucideIcon,
} from 'lucide-react';
export interface NavItem {
id: string;
label: string;
icon: LucideIcon;
}
export interface NavSection {
title: string;
items: NavItem[];
}
export const DOC_SECTIONS: NavSection[] = [
{
title: 'Démarrage',
items: [
{ id: 'home', label: 'Vue d\'ensemble', icon: Home },
{ id: 'quickstart', label: 'Guide de démarrage', icon: Zap },
],
},
{
title: 'Authentification',
items: [
{ id: 'authentication', label: 'Clés API', icon: Key },
],
},
{
title: 'Ressources API',
items: [
{ id: 'bookings', label: 'Bookings', icon: Package },
{ id: 'rates', label: 'Tarifs', icon: TrendingUp },
{ id: 'organizations', label: 'Organisations', icon: Building2 },
],
},
{
title: 'Référence',
items: [
{ id: 'endpoints', label: 'Tous les endpoints', icon: List },
{ id: 'errors', label: 'Codes d\'erreur', icon: AlertTriangle },
{ id: 'rate-limiting', label: 'Rate Limiting', icon: ShieldCheck },
],
},
];
export const ALL_NAV_ITEMS: NavItem[] = DOC_SECTIONS.flatMap(s => s.items);
export function findNavItem(id: string): NavItem | undefined {
return ALL_NAV_ITEMS.find(item => item.id === id);
}

View File

@ -0,0 +1,55 @@
/**
* API Keys API
*
* Endpoints for managing API keys (Gold and Platinum plans only)
*/
import { get, post, del } from './client';
export interface ApiKeyDto {
id: string;
name: string;
keyPrefix: string;
isActive: boolean;
lastUsedAt: string | null;
expiresAt: string | null;
createdAt: string;
}
export interface CreateApiKeyResultDto extends ApiKeyDto {
/** Full key — shown only once at creation time */
fullKey: string;
}
export interface CreateApiKeyRequest {
name: string;
expiresAt?: string;
}
/**
* List all API keys for the current organization
* GET /api-keys
* Requires: Gold or Platinum plan
*/
export async function listApiKeys(): Promise<ApiKeyDto[]> {
return get<ApiKeyDto[]>('/api-keys');
}
/**
* Create a new API key
* POST /api-keys
* Requires: Gold or Platinum plan
* Returns the full key shown only once
*/
export async function createApiKey(data: CreateApiKeyRequest): Promise<CreateApiKeyResultDto> {
return post<CreateApiKeyResultDto>('/api-keys', data);
}
/**
* Revoke an API key (immediate and irreversible)
* DELETE /api-keys/:id
* Requires: Gold or Platinum plan
*/
export async function revokeApiKey(id: string): Promise<void> {
return del<void>(`/api-keys/${id}`);
}

View File

@ -0,0 +1,334 @@
# Accès API Xpeditis — Documentation
> **Disponible sur :** plans **Gold** et **Platinium** uniquement.
> Les plans Bronze et Silver n'ont accès qu'au frontend Xpeditis.
---
## Table des matières
1. [Vue d'ensemble](#vue-densemble)
2. [Authentification par clé API](#authentification-par-clé-api)
3. [Gestion des clés API](#gestion-des-clés-api)
- [Générer une clé](#générer-une-clé)
- [Lister les clés](#lister-les-clés)
- [Révoquer une clé](#révoquer-une-clé)
4. [Utiliser l'API](#utiliser-lapi)
5. [Sécurité et bonnes pratiques](#sécurité-et-bonnes-pratiques)
6. [Limites et quotas](#limites-et-quotas)
7. [Codes d'erreur](#codes-derreur)
8. [Exemples d'intégration](#exemples-dintégration)
---
## Vue d'ensemble
L'accès API permet aux abonnés **Gold** et **Platinium** d'intégrer Xpeditis directement dans leurs systèmes (ERP, TMS, scripts d'automatisation, etc.) sans passer par l'interface web.
### Deux méthodes d'authentification coexistent
| Méthode | En-tête HTTP | Disponible pour |
|---|---|---|
| **JWT Bearer** (frontend) | `Authorization: Bearer <token>` | Tous les plans |
| **Clé API** (accès programmatique) | `X-API-Key: <clé>` | Gold + Platinium uniquement |
Les deux méthodes donnent accès aux mêmes endpoints. La clé API ne nécessite pas de session interactive.
---
## Authentification par clé API
### Format de la clé
```
xped_live_<64 caractères hexadécimaux>
```
Exemple :
```
xped_live_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
```
### Utilisation dans une requête
Ajoutez l'en-tête `X-API-Key` à chaque requête :
```http
GET /api/v1/bookings HTTP/1.1
Host: api.xpeditis.com
X-API-Key: xped_live_a1b2c3d4e5f6...
Content-Type: application/json
```
### Comportement du serveur
1. Le serveur détecte l'en-tête `X-API-Key`.
2. Il calcule le hash SHA-256 de la clé et le compare à la base de données.
3. Il vérifie que la clé est active et non expirée.
4. Il vérifie **en temps réel** que l'organisation possède toujours un abonnement Gold ou Platinium.
5. Si tout est valide, la requête est authentifiée.
> Si votre abonnement est rétrogradé sous Gold, vos clés API seront automatiquement refusées même si elles sont techniquement encore actives.
---
## Gestion des clés API
Les endpoints de gestion des clés nécessitent une **authentification JWT** (via le frontend ou un token Bearer). Ils ne sont pas accessibles avec une clé API elle-même.
**Base URL :** `http://localhost:4000` (développement) ou `https://api.xpeditis.com` (production)
---
### Générer une clé
```http
POST /api-keys
Authorization: Bearer <access_token>
Content-Type: application/json
{
"name": "Intégration ERP Production",
"expiresAt": "2027-01-01T00:00:00.000Z"
}
```
**Corps de la requête :**
| Champ | Type | Requis | Description |
|---|---|---|---|
| `name` | string | ✅ | Nom identifiant la clé (max 100 caractères) |
| `expiresAt` | string (ISO 8601) | ❌ | Date d'expiration. Si absent, la clé n'expire pas. |
**Réponse `201 Created` :**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Intégration ERP Production",
"keyPrefix": "xped_live_a1b2c3d4",
"isActive": true,
"lastUsedAt": null,
"expiresAt": "2027-01-01T00:00:00.000Z",
"createdAt": "2025-03-26T10:00:00.000Z",
"fullKey": "xped_live_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
}
```
> ⚠️ **Le champ `fullKey` n'est retourné qu'une seule fois.** Copiez-le immédiatement et stockez-le de façon sécurisée (gestionnaire de secrets, variables d'environnement chiffrées, etc.). Il ne sera plus jamais visible.
---
### Lister les clés
```http
GET /api-keys
Authorization: Bearer <access_token>
```
**Réponse `200 OK` :**
```json
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Intégration ERP Production",
"keyPrefix": "xped_live_a1b2c3d4",
"isActive": true,
"lastUsedAt": "2025-03-25T14:30:00.000Z",
"expiresAt": "2027-01-01T00:00:00.000Z",
"createdAt": "2025-03-26T10:00:00.000Z"
},
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"name": "Script de reporting mensuel",
"keyPrefix": "xped_live_b2c3d4e5",
"isActive": false,
"lastUsedAt": "2025-02-01T09:00:00.000Z",
"expiresAt": null,
"createdAt": "2025-01-15T08:00:00.000Z"
}
]
```
> Les clés complètes ne sont jamais retournées dans cet endpoint. Seul le préfixe est visible.
---
### Révoquer une clé
```http
DELETE /api-keys/{id}
Authorization: Bearer <access_token>
```
**Réponse `204 No Content`** en cas de succès.
> La révocation est **immédiate et irréversible**. Toute requête utilisant cette clé sera refusée à partir de cet instant. Pour réactiver l'accès, créez une nouvelle clé.
---
## Utiliser l'API
Une fois votre clé générée, vous pouvez l'utiliser sur tous les endpoints de l'API Xpeditis.
### Exemple : Récupérer les bookings
```http
GET /bookings
X-API-Key: xped_live_a1b2c3d4e5f6...
```
### Exemple : Créer un booking
```http
POST /bookings
X-API-Key: xped_live_a1b2c3d4e5f6...
Content-Type: application/json
{
"rateQuoteId": "...",
...
}
```
### Référence complète
La documentation Swagger interactive est disponible à :
```
http://localhost:4000/api/docs
```
Elle liste tous les endpoints disponibles avec leurs paramètres et schémas de réponse.
---
## Sécurité et bonnes pratiques
### Stockage des clés
- ❌ Ne stockez **jamais** une clé API en dur dans votre code source ou vos dépôts Git.
- ✅ Utilisez des **variables d'environnement** (`XPEDITIS_API_KEY=xped_live_...`).
- ✅ Utilisez un **gestionnaire de secrets** (AWS Secrets Manager, HashiCorp Vault, etc.) en production.
### Rotation des clés
Effectuez une rotation régulière de vos clés :
1. Créez une nouvelle clé (sans supprimer l'ancienne).
2. Mettez à jour votre système avec la nouvelle clé.
3. Vérifiez que tout fonctionne.
4. Révoquez l'ancienne clé.
### Rotation d'urgence
En cas de compromission suspectée :
1. Révoquez immédiatement la clé compromise via `DELETE /api-keys/{id}`.
2. Créez une nouvelle clé.
3. Auditez vos logs d'accès.
### Permissions
Une clé API agit au nom de l'utilisateur qui l'a créée, avec son rôle (MANAGER, USER, etc.) et les permissions de son organisation. Les clés héritent des restrictions d'accès de l'utilisateur créateur.
---
## Limites et quotas
| Limite | Valeur |
|---|---|
| Nombre de clés actives par organisation | 20 |
| Longueur du nom d'une clé | 100 caractères max |
| Rate limiting | Identique aux autres requêtes API |
Le rate limiting est appliqué par utilisateur (basé sur l'ID utilisateur associé à la clé).
---
## Codes d'erreur
| Code HTTP | Description | Solution |
|---|---|---|
| `401 Unauthorized` | Clé invalide, expirée ou révoquée | Vérifiez la clé ou créez-en une nouvelle |
| `401 Unauthorized` | Clé bien formée mais hash inconnu | La clé a peut-être été révoquée ou n'existe pas |
| `403 Forbidden` | Abonnement insuffisant | Passez au plan Gold ou Platinium |
| `403 Forbidden` | Abonnement rétrogradé après création de la clé | Mettez à niveau votre abonnement |
| `404 Not Found` | Clé introuvable lors d'une révocation | Vérifiez l'ID de la clé |
---
## Exemples d'intégration
### cURL
```bash
# Lister les bookings
curl -X GET https://api.xpeditis.com/bookings \
-H "X-API-Key: xped_live_votre_cle_ici" \
-H "Content-Type: application/json"
```
### Node.js / TypeScript
```typescript
const XPEDITIS_API_KEY = process.env.XPEDITIS_API_KEY;
const BASE_URL = 'https://api.xpeditis.com';
async function getBookings() {
const response = await fetch(`${BASE_URL}/bookings`, {
headers: {
'X-API-Key': XPEDITIS_API_KEY!,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Xpeditis API error: ${response.status}`);
}
return response.json();
}
```
### Python
```python
import os
import requests
API_KEY = os.environ['XPEDITIS_API_KEY']
BASE_URL = 'https://api.xpeditis.com'
def get_bookings():
response = requests.get(
f'{BASE_URL}/bookings',
headers={
'X-API-Key': API_KEY,
'Content-Type': 'application/json',
}
)
response.raise_for_status()
return response.json()
```
### PHP
```php
$apiKey = getenv('XPEDITIS_API_KEY');
$baseUrl = 'https://api.xpeditis.com';
$ch = curl_init("$baseUrl/bookings");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"X-API-Key: $apiKey",
'Content-Type: application/json',
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true);
```
---
*Documentation Xpeditis — Accès API v1.0*
*Disponible uniquement sur les plans Gold (899 €/mois) et Platinium (sur devis)*