diff --git a/CLAUDE.md b/CLAUDE.md index c26bad0..3f17afb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 6675df5..898c0c3 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -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 { diff --git a/apps/backend/src/application/api-keys/api-keys.controller.ts b/apps/backend/src/application/api-keys/api-keys.controller.ts new file mode 100644 index 0000000..b2bb476 --- /dev/null +++ b/apps/backend/src/application/api-keys/api-keys.controller.ts @@ -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 { + 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 { + 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 { + return this.apiKeysService.revokeApiKey(keyId, user.organizationId); + } +} diff --git a/apps/backend/src/application/api-keys/api-keys.module.ts b/apps/backend/src/application/api-keys/api-keys.module.ts new file mode 100644 index 0000000..d3a67aa --- /dev/null +++ b/apps/backend/src/application/api-keys/api-keys.module.ts @@ -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 {} diff --git a/apps/backend/src/application/api-keys/api-keys.service.ts b/apps/backend/src/application/api-keys/api-keys.service.ts new file mode 100644 index 0000000..eeac338 --- /dev/null +++ b/apps/backend/src/application/api-keys/api-keys.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/apps/backend/src/application/dto/api-key.dto.ts b/apps/backend/src/application/dto/api-key.dto.ts new file mode 100644 index 0000000..17b9e17 --- /dev/null +++ b/apps/backend/src/application/dto/api-key.dto.ts @@ -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; +} diff --git a/apps/backend/src/application/guards/api-key-or-jwt.guard.ts b/apps/backend/src/application/guards/api-key-or-jwt.guard.ts new file mode 100644 index 0000000..e910831 --- /dev/null +++ b/apps/backend/src/application/guards/api-key-or-jwt.guard.ts @@ -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 `) + * - 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 { + const request = context.switchToHttp().getRequest>(); + 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; + } +} diff --git a/apps/backend/src/application/guards/index.ts b/apps/backend/src/application/guards/index.ts index e174be2..374d66f 100644 --- a/apps/backend/src/application/guards/index.ts +++ b/apps/backend/src/application/guards/index.ts @@ -1,2 +1,3 @@ export * from './jwt-auth.guard'; export * from './roles.guard'; +export * from './api-key-or-jwt.guard'; diff --git a/apps/backend/src/domain/entities/api-key.entity.ts b/apps/backend/src/domain/entities/api-key.entity.ts new file mode 100644 index 0000000..f0a48fa --- /dev/null +++ b/apps/backend/src/domain/entities/api-key.entity.ts @@ -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 }; + } +} diff --git a/apps/backend/src/domain/ports/out/api-key.repository.ts b/apps/backend/src/domain/ports/out/api-key.repository.ts new file mode 100644 index 0000000..ceece42 --- /dev/null +++ b/apps/backend/src/domain/ports/out/api-key.repository.ts @@ -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; + findById(id: string): Promise; + findByKeyHash(keyHash: string): Promise; + findByOrganizationId(organizationId: string): Promise; + delete(id: string): Promise; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/api-key.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/api-key.orm-entity.ts new file mode 100644 index 0000000..bca7e0a --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/api-key.orm-entity.ts @@ -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; +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/api-key-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/api-key-orm.mapper.ts new file mode 100644 index 0000000..dd48a69 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/api-key-orm.mapper.ts @@ -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)); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1741000000001-CreateApiKeysTable.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1741000000001-CreateApiKeysTable.ts new file mode 100644 index 0000000..c443352 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1741000000001-CreateApiKeysTable.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS "api_keys" CASCADE`); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-api-key.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-api-key.repository.ts new file mode 100644 index 0000000..72b6c84 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-api-key.repository.ts @@ -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 + ) {} + + async save(apiKey: ApiKey): Promise { + const orm = ApiKeyOrmMapper.toOrm(apiKey); + const saved = await this.repo.save(orm); + return ApiKeyOrmMapper.toDomain(saved); + } + + async findById(id: string): Promise { + const orm = await this.repo.findOne({ where: { id } }); + return orm ? ApiKeyOrmMapper.toDomain(orm) : null; + } + + async findByKeyHash(keyHash: string): Promise { + const orm = await this.repo.findOne({ where: { keyHash } }); + return orm ? ApiKeyOrmMapper.toDomain(orm) : null; + } + + async findByOrganizationId(organizationId: string): Promise { + const orms = await this.repo.find({ + where: { organizationId }, + order: { createdAt: 'DESC' }, + }); + return ApiKeyOrmMapper.toDomainMany(orms); + } + + async delete(id: string): Promise { + await this.repo.delete({ id }); + } +} diff --git a/apps/frontend/app/dashboard/docs/page.tsx b/apps/frontend/app/dashboard/docs/page.tsx new file mode 100644 index 0000000..6816fcf --- /dev/null +++ b/apps/frontend/app/dashboard/docs/page.tsx @@ -0,0 +1,1113 @@ +'use client'; + +import { useState, useEffect, Suspense } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import { + Search, + ChevronRight, + ArrowRight, + ExternalLink, + Zap, + Key, + Package, + TrendingUp, + Building2, + ShieldCheck, + AlertTriangle, + List, + Home, + Menu, + X, + CheckCircle2, + Clock, + Info, +} from 'lucide-react'; +import { CodeBlock } from '@/components/docs/CodeBlock'; +import { DOC_SECTIONS, ALL_NAV_ITEMS } from '@/components/docs/docsNav'; + +// ─── Shared sub-components ──────────────────────────────────────────────────── + +function H2({ children }: { children: React.ReactNode }) { + return

{children}

; +} +function H3({ children }: { children: React.ReactNode }) { + return

{children}

; +} +function P({ children }: { children: React.ReactNode }) { + return

{children}

; +} +function Divider() { + return
; +} +function Callout({ type = 'info', children }: { type?: 'info' | 'warning' | 'success'; children: React.ReactNode }) { + const styles = { + info: 'bg-blue-50 border-blue-200 text-blue-800', + warning: 'bg-amber-50 border-amber-200 text-amber-800', + success: 'bg-green-50 border-green-200 text-green-800', + }; + const icons = { info: Info, warning: AlertTriangle, success: CheckCircle2 }; + const Icon = icons[type]; + return ( +
+ +
{children}
+
+ ); +} +function InlineCode({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +function Table({ headers, rows }: { headers: string[]; rows: (string | React.ReactNode)[][] }) { + return ( +
+ + + + {headers.map(h => ( + + ))} + + + + {rows.map((row, i) => ( + + {row.map((cell, j) => ( + + ))} + + ))} + +
+ {h} +
+ {cell} +
+
+ ); +} +function SectionHeader({ title, description, badge }: { title: string; description: string; badge?: string }) { + return ( +
+ {badge && ( + + {badge} + + )} +

{title}

+

{description}

+
+ ); +} + +// ─── Section: Home ──────────────────────────────────────────────────────────── + +function HomeSection({ onNavigate }: { onNavigate: (id: string) => void }) { + const cards = [ + { + id: 'quickstart', + icon: Zap, + title: 'Guide de démarrage', + description: 'Faites votre première requête API en moins de 5 minutes.', + color: 'text-amber-500', + bg: 'bg-amber-50', + }, + { + id: 'authentication', + icon: Key, + title: 'Authentification', + description: 'Créez et gérez vos clés API pour accéder à la plateforme.', + color: 'text-blue-500', + bg: 'bg-blue-50', + }, + { + id: 'bookings', + icon: Package, + title: 'Bookings', + description: 'Créez, consultez et gérez des réservations de fret maritime.', + color: 'text-violet-500', + bg: 'bg-violet-50', + }, + { + id: 'rates', + icon: TrendingUp, + title: 'Tarifs & Recherche', + description: 'Recherchez et comparez des tarifs en temps réel.', + color: 'text-emerald-500', + bg: 'bg-emerald-50', + }, + { + id: 'organizations', + icon: Building2, + title: 'Organisations', + description: 'Accédez aux données de votre organisation.', + color: 'text-orange-500', + bg: 'bg-orange-50', + }, + { + id: 'endpoints', + icon: List, + title: 'Référence complète', + description: 'Tous les endpoints, paramètres et réponses.', + color: 'text-gray-500', + bg: 'bg-gray-50', + }, + ]; + + return ( +
+ {/* Hero */} +
+
+
+
+ + Plans Gold & Platinium uniquement +
+

+ API Xpeditis +

+

+ Intégrez la puissance de la freight logistics maritime dans vos applications. + Tarifs en temps réel, gestion de bookings, suivi d'expéditions. +

+
+ + +
+
+
+ + {/* Cards grid */} +
+

Explorer la documentation

+
+ {cards.map(card => ( + + ))} +
+
+ + {/* Base URL */} + +

URL de base

+

Toutes les requêtes API doivent être envoyées à l'URL de base suivante :

+ +

En environnement de développement local :

+ +
+ ); +} + +// ─── Section: Quick Start ───────────────────────────────────────────────────── + +function QuickStartSection({ onNavigate }: { onNavigate: (id: string) => void }) { + return ( +
+ + + + L'accès API est disponible uniquement sur les plans Gold et Platinium. + Rendez-vous dans Paramètres → Abonnement pour upgrader. + + + {/* Step 1 */} +
+
1
+
+

Obtenir votre clé API

+

+ Dans le dashboard, rendez-vous dans Paramètres → Clés API, puis cliquez sur Créer une clé. + La clé complète vous sera montrée une seule fois — conservez-la immédiatement. +

+

Format de la clé :

+ +
+
+ + {/* Step 2 */} +
+
2
+
+

Faire votre première requête

+

Passez la clé dans l'en-tête X-API-Key :

+ + +
+
+ + {/* Step 3 */} +
+
3
+
+

Lire la réponse

+

Toutes les réponses sont en JSON. En cas de succès :

+ +

En cas d'erreur :

+ +
+
+ + +

Étapes suivantes

+
+ {[ + { id: 'authentication', label: 'Gérer vos clés API', desc: 'Créer, lister et révoquer des clés' }, + { id: 'bookings', label: 'Créer un booking', desc: 'Réservez du fret maritime via l\'API' }, + { id: 'rates', label: 'Rechercher des tarifs', desc: 'Comparez les tarifs en temps réel' }, + { id: 'errors', label: 'Gestion des erreurs', desc: 'Tous les codes d\'erreur expliqués' }, + ].map(item => ( + + ))} +
+
+ ); +} + +// ─── Section: Authentication ────────────────────────────────────────────────── + +function AuthenticationSection() { + return ( +
+ + +

Vue d'ensemble

+

+ L'API Xpeditis utilise des clés API pour authentifier les requêtes. + Transmettez votre clé dans l'en-tête X-API-Key de chaque requête. +

+ + Vos clés API sont confidentielles. Ne les partagez jamais dans du code public, des dépôts Git ou des forums. + En cas de compromission, révoquez immédiatement la clé depuis le dashboard. + + + +

Format de la clé

+

Toutes les clés Xpeditis commencent par xped_live_ suivi de 64 caractères hexadécimaux :

+ + + +

Utilisation

+

Header HTTP

+

Passez votre clé dans l'en-tête X-API-Key :

+ + +

Exemples par langage

+ + + + + +

Endpoints de gestion

+

Ces endpoints nécessitent une authentification par token JWT (connexion via le dashboard) et non une clé API.

+ + + +

Créer une clé

+ " \\ + -H "Content-Type: application/json" \\ + -d '{ + "name": "Intégration ERP Production", + "expiresAt": "2027-01-01T00:00:00.000Z" + }'`} + /> + + + Le champ fullKey est retourné une seule fois. + Stockez-le immédiatement dans un gestionnaire de secrets. + + + +

Sécurité

+

Stockage recommandé

+
+

Rotation des clés

+

+ Effectuez une rotation régulière (tous les 90 jours recommandé) : créez une nouvelle clé, migrez votre système, + puis révoquez l'ancienne. +

+ + ); +} + +// ─── Section: Bookings ──────────────────────────────────────────────────────── + +function BookingsSection() { + return ( +
+ + +

Lister les bookings

+ +
status, 'string', 'Filtrer par statut (draft, confirmed, in_transit, delivered, cancelled)'], + [page, 'number', 'Numéro de page (défaut: 1)'], + [limit, 'number', 'Résultats par page (défaut: 20, max: 100)'], + [origin, 'string', 'Code port d\'origine (ex: FRLEH)'], + [destination, 'string', 'Code port de destination (ex: CNSHA)'], + ]} + /> + + + +

Créer un booking

+ + + + +

Statuts d'un booking

+
draft, 'Réservation créée, non confirmée'], + [pending_confirmation, 'En attente de confirmation du transporteur'], + [confirmed, 'Confirmée par le transporteur'], + [in_transit, 'Expédition en cours'], + [delivered, 'Livraison confirmée'], + [cancelled, 'Annulée'], + ]} + /> + + ); +} + +// ─── Section: Rates ─────────────────────────────────────────────────────────── + +function RatesSection() { + return ( +
+ + +

Rechercher des tarifs

+ +
origin, '✅', 'Code port d\'origine (UN/LOCODE, ex: FRLEH)'], + [destination, '✅', 'Code port de destination (ex: CNSHA)'], + [containerType, '✅', 'Type de conteneur: 20GP, 40GP, 40HC, 45HC, 20FR, 40FR'], + [departureDate, '❌', 'Date souhaitée de départ (YYYY-MM-DD)'], + [sortBy, '❌', 'Tri: price_asc | price_desc | transit_time'], + ]} + /> + + + + Les tarifs sont mis en cache pendant 15 minutes. Au-delà, une nouvelle recherche est effectuée + auprès des transporteurs en temps réel. + + + +

Codes de ports (UN/LOCODE)

+

Les ports sont identifiés par le code standard UN/LOCODE (5 caractères).

+
+ + + ); +} + +// ─── Section: Organizations ─────────────────────────────────────────────────── + +function OrganizationsSection() { + return ( +
+ + +

Profil de l'organisation

+ + + + +

Membres de l'organisation

+ +
+ ); +} + +// ─── Section: Endpoints ─────────────────────────────────────────────────────── + +function EndpointsSection() { + const endpoints = [ + { method: 'GET', path: '/bookings', desc: 'Lister les bookings' }, + { method: 'POST', path: '/bookings', desc: 'Créer un booking' }, + { method: 'GET', path: '/bookings/:id', desc: 'Détail d\'un booking' }, + { method: 'PATCH', path: '/bookings/:id/status', desc: 'Mettre à jour le statut' }, + { method: 'GET', path: '/rates/search', desc: 'Rechercher des tarifs' }, + { method: 'GET', path: '/rates/:id', desc: 'Détail d\'un tarif' }, + { method: 'GET', path: '/ports', desc: 'Lister les ports' }, + { method: 'GET', path: '/organizations/me', desc: 'Profil de l\'organisation' }, + { method: 'GET', path: '/users', desc: 'Membres de l\'organisation' }, + { method: 'POST', path: '/api-keys', desc: 'Créer une clé API (JWT requis)' }, + { method: 'GET', path: '/api-keys', desc: 'Lister les clés API (JWT requis)' }, + { method: 'DELETE', path: '/api-keys/:id', desc: 'Révoquer une clé (JWT requis)' }, + ]; + + const methodColor: Record = { + GET: 'text-emerald-700 bg-emerald-50 border-emerald-200', + POST: 'text-blue-700 bg-blue-50 border-blue-200', + PATCH: 'text-amber-700 bg-amber-50 border-amber-200', + DELETE: 'text-red-700 bg-red-50 border-red-200', + }; + + return ( +
+ + +

Endpoints disponibles

+
+ {endpoints.map((ep, i) => ( +
+ + {ep.method} + + {ep.path} + {ep.desc} +
+ ))} +
+ + +

Format de réponse standard

+ +
+ ); +} + +// ─── Section: Errors ───────────────────────────────────────────────────────── + +function ErrorsSection() { + return ( +
+ + +

Format d'erreur

+

Toutes les erreurs retournent un JSON standardisé :

+ + + +

Codes HTTP

+
+ + +

Erreurs courantes

+

401 — Clé API invalide

+ +

Solutions : Vérifiez que la clé commence par xped_live_, qu'elle n'est pas révoquée et que l'abonnement est toujours Gold ou Platinium.

+ +

403 — Plan insuffisant

+ + +

429 — Rate limit

+ +

En cas de 429, attendez la durée indiquée dans retryAfter avant de réessayer.

+ + ); +} + +// ─── Section: Rate Limiting ─────────────────────────────────────────────────── + +function RateLimitingSection() { + return ( +
+ + +

Limites par plan

+
+ + Le rate limiting est appliqué par utilisateur (ID de l'utilisateur associé à la clé API). + + + +

En-têtes de réponse

+

Chaque réponse inclut des en-têtes pour suivre votre consommation :

+
+ + +

Bonnes pratiques

+
+ {[ + { icon: Clock, title: 'Exponential backoff', desc: 'En cas de 429, attendez avant de réessayer (1s, 2s, 4s, 8s…).' }, + { icon: CheckCircle2, title: 'Mise en cache', desc: 'Cachez les résultats côté client pour éviter les appels redondants. Les tarifs restent valides 15 minutes.' }, + { icon: ShieldCheck, title: 'Une clé par service', desc: 'Utilisez des clés séparées par service ou environnement pour un meilleur suivi et une révocation ciblée.' }, + ].map((item, i) => ( +
+ +
+

{item.title}

+

{item.desc}

+
+
+ ))} +
+ + ); +} + +// ─── Section map ────────────────────────────────────────────────────────────── + +function SectionContent({ + activeSection, + onNavigate, +}: { + activeSection: string; + onNavigate: (id: string) => void; +}) { + switch (activeSection) { + case 'home': return ; + case 'quickstart': return ; + case 'authentication': return ; + case 'bookings': return ; + case 'rates': return ; + case 'organizations': return ; + case 'endpoints': return ; + case 'errors': return ; + case 'rate-limiting': return ; + default: return ; + } +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── + +function DocsPageContent() { + const searchParams = useSearchParams(); + const router = useRouter(); + const [activeSection, setActiveSection] = useState(searchParams.get('section') ?? 'home'); + const [searchQuery, setSearchQuery] = useState(''); + const [sidebarOpen, setSidebarOpen] = useState(false); + + const navigate = (id: string) => { + setActiveSection(id); + router.replace(`/dashboard/docs?section=${id}`, { scroll: false }); + setSidebarOpen(false); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + // Filter nav based on search + const filteredSections = DOC_SECTIONS.map(section => ({ + ...section, + items: section.items.filter(item => + item.label.toLowerCase().includes(searchQuery.toLowerCase()) + ), + })).filter(s => s.items.length > 0); + + const Sidebar = () => ( +
+ {/* Logo + title */} +
+
+
+ X +
+ Xpeditis + / API +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="w-full pl-8 pr-3 py-1.5 text-sm bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#34CCCD]/30 focus:border-[#34CCCD] placeholder-gray-400" + /> +
+
+ + {/* Navigation */} + + + {/* Footer */} + +
+ ); + + return ( + /* Break out of dashboard's p-6 padding */ +
+ {/* Mobile sidebar overlay */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + {/* Docs sidebar — desktop */} +
+ +
+ + {/* Docs sidebar — mobile drawer */} +
+ +
+ + {/* Main content */} +
+ {/* Mobile top bar */} +
+ + + {ALL_NAV_ITEMS.find(i => i.id === activeSection)?.label ?? 'Documentation'} + +
+ +
+ {/* Breadcrumb */} +
+ + {activeSection !== 'home' && ( + <> + + + {ALL_NAV_ITEMS.find(i => i.id === activeSection)?.label} + + + )} +
+ + + + {/* Bottom nav */} + {activeSection !== 'home' && ( +
+ +
+ )} +
+
+
+ ); +} + +export default function DocsPage() { + return ( +
}> + +
+ ); +} diff --git a/apps/frontend/app/dashboard/layout.tsx b/apps/frontend/app/dashboard/layout.tsx index 2a45b19..8f785b9 100644 --- a/apps/frontend/app/dashboard/layout.tsx +++ b/apps/frontend/app/dashboard/layout.tsx @@ -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 }, diff --git a/apps/frontend/app/dashboard/settings/api-keys/page.tsx b/apps/frontend/app/dashboard/settings/api-keys/page.tsx new file mode 100644 index 0000000..bfa95ca --- /dev/null +++ b/apps/frontend/app/dashboard/settings/api-keys/page.tsx @@ -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 ( + + Révoquée + + ); + } + if (key.expiresAt && new Date(key.expiresAt) < new Date()) { + return ( + + + Expirée + + ); + } + return ( + + Active + + ); +} + +// ─── 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 ( + + ); +} + +// ─── Creation success modal ────────────────────────────────────────────────── + +function CreatedKeyModal({ + result, + onClose, +}: { + result: CreateApiKeyResultDto; + onClose: () => void; +}) { + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

Clé API créée

+

{result.name}

+
+
+ +
+ + {/* Warning */} +
+ +

+ Copiez cette clé maintenant. Elle ne sera plus jamais affichée après + la fermeture de cette fenêtre. +

+
+ + {/* Key */} +
+ +
+ + {result.fullKey} + + +
+

+ Stockez-la dans vos variables d'environnement ou un gestionnaire de secrets. +

+
+ + {/* Footer */} +
+ +
+
+
+ ); +} + +// ─── 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 ( +
+
+ {/* Header */} +
+
+
+ +
+

Nouvelle clé API

+
+ +
+ +
+ {/* Name */} +
+ + 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" + /> +

{name.length}/100 caractères

+
+ + {/* Expiry */} +
+ + 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" + /> +

+ Si vide, la clé n'expire jamais. +

+
+ + {/* Error */} + {mutation.isError && ( +
+ + Une erreur est survenue. Veuillez réessayer. +
+ )} + + {/* Actions */} +
+ + +
+ +
+
+ ); +} + +// ─── Revoke confirm modal ──────────────────────────────────────────────────── + +function RevokeConfirmModal({ + apiKey, + onConfirm, + onClose, +}: { + apiKey: ApiKeyDto; + onConfirm: () => void; + onClose: () => void; +}) { + return ( +
+
+
+
+ +
+

+ Révoquer cette clé ? +

+

+ {apiKey.name} +

+

+ Cette action est immédiate et irréversible. Toute requête utilisant + cette clé sera refusée. +

+
+
+ + +
+
+
+ ); +} + +// ─── 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(null); + const [revokeTarget, setRevokeTarget] = useState(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 ( +
+
+ +
+

Accès API

+

+ L'accès programmatique à l'API Xpeditis est disponible sur les plans{' '} + Gold et Platinium uniquement. +

+ + Voir les plans + +
+ ); + } + + const activeKeys = apiKeys?.filter(k => k.isActive) ?? []; + + return ( + <> + {/* Modals */} + {showCreateModal && ( + { + setShowCreateModal(false); + setCreatedKey(result); + }} + onClose={() => setShowCreateModal(false)} + /> + )} + {createdKey && ( + setCreatedKey(null)} /> + )} + {revokeTarget && ( + revokeMutation.mutate(revokeTarget.id)} + onClose={() => setRevokeTarget(null)} + /> + )} + + {/* Page header */} +
+
+

Clés API

+

+ Gérez les clés d'accès programmatique à l'API Xpeditis. +

+
+ +
+ + {/* Info banner */} +
+ +
+

Comment utiliser vos clés API

+

+ Ajoutez l'en-tête{' '} + + X-API-Key: xped_live_... + {' '} + à chaque requête HTTP.{' '} + + Voir la documentation + +

+
+
+ + {/* Keys list */} +
+ {isLoading ? ( +
+
+
+ ) : !apiKeys || apiKeys.length === 0 ? ( +
+ +

Aucune clé API pour le moment.

+ +
+ ) : ( +
+ {/* Table header */} +
+ Nom / Préfixe + Dernière utilisation + Expiration + Statut + +
+ + {apiKeys.map(key => ( +
+ {/* Name + prefix */} +
+

{key.name}

+ {key.keyPrefix}… +
+ + {/* Last used */} + {formatDate(key.lastUsedAt)} + + {/* Expiry */} + {formatDate(key.expiresAt)} + + {/* Status */} +
{keyStatusBadge(key)}
+ + {/* Actions */} + +
+ ))} +
+ )} +
+ + {/* Quota */} + {apiKeys && apiKeys.length > 0 && ( +

+ {activeKeys.length} / 20 clés actives utilisées +

+ )} + + ); +} diff --git a/apps/frontend/src/components/docs/CodeBlock.tsx b/apps/frontend/src/components/docs/CodeBlock.tsx new file mode 100644 index 0000000..61d6cbe --- /dev/null +++ b/apps/frontend/src/components/docs/CodeBlock.tsx @@ -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 ( +
+ {/* Header */} +
+
+ {filename && ( + {filename} + )} + {!filename && ( + + {language} + + )} +
+ +
+ {/* Code */} +
+
+          {code.trim()}
+        
+
+
+ ); +} diff --git a/apps/frontend/src/components/docs/docsNav.ts b/apps/frontend/src/components/docs/docsNav.ts new file mode 100644 index 0000000..22e34aa --- /dev/null +++ b/apps/frontend/src/components/docs/docsNav.ts @@ -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); +} diff --git a/apps/frontend/src/lib/api/api-keys.ts b/apps/frontend/src/lib/api/api-keys.ts new file mode 100644 index 0000000..0a6dd6d --- /dev/null +++ b/apps/frontend/src/lib/api/api-keys.ts @@ -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 { + return get('/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 { + return post('/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 { + return del(`/api-keys/${id}`); +} diff --git a/docs/api-access/API_ACCESS.md b/docs/api-access/API_ACCESS.md new file mode 100644 index 0000000..f5bb715 --- /dev/null +++ b/docs/api-access/API_ACCESS.md @@ -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 ` | Tous les plans | +| **Clé API** (accès programmatique) | `X-API-Key: ` | 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 +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 +``` + +**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 +``` + +**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)*