fix documentation et api key
This commit is contained in:
parent
6adcb2b9f8
commit
ccc64b939a
21
CLAUDE.md
21
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
|
||||
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
81
apps/backend/src/application/api-keys/api-keys.controller.ts
Normal file
81
apps/backend/src/application/api-keys/api-keys.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
45
apps/backend/src/application/api-keys/api-keys.module.ts
Normal file
45
apps/backend/src/application/api-keys/api-keys.module.ts
Normal 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 {}
|
||||
200
apps/backend/src/application/api-keys/api-keys.service.ts
Normal file
200
apps/backend/src/application/api-keys/api-keys.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
63
apps/backend/src/application/dto/api-key.dto.ts
Normal file
63
apps/backend/src/application/dto/api-key.dto.ts
Normal 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;
|
||||
}
|
||||
55
apps/backend/src/application/guards/api-key-or-jwt.guard.ts
Normal file
55
apps/backend/src/application/guards/api-key-or-jwt.guard.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,3 @@
|
||||
export * from './jwt-auth.guard';
|
||||
export * from './roles.guard';
|
||||
export * from './api-key-or-jwt.guard';
|
||||
|
||||
135
apps/backend/src/domain/entities/api-key.entity.ts
Normal file
135
apps/backend/src/domain/entities/api-key.entity.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
11
apps/backend/src/domain/ports/out/api-key.repository.ts
Normal file
11
apps/backend/src/domain/ports/out/api-key.repository.ts
Normal 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>;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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`);
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
1113
apps/frontend/app/dashboard/docs/page.tsx
Normal file
1113
apps/frontend/app/dashboard/docs/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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 },
|
||||
|
||||
489
apps/frontend/app/dashboard/settings/api-keys/page.tsx
Normal file
489
apps/frontend/app/dashboard/settings/api-keys/page.tsx
Normal 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'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'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'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'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'accès programmatique à l'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'accès programmatique à l'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'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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
60
apps/frontend/src/components/docs/CodeBlock.tsx
Normal file
60
apps/frontend/src/components/docs/CodeBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
apps/frontend/src/components/docs/docsNav.ts
Normal file
61
apps/frontend/src/components/docs/docsNav.ts
Normal 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);
|
||||
}
|
||||
55
apps/frontend/src/lib/api/api-keys.ts
Normal file
55
apps/frontend/src/lib/api/api-keys.ts
Normal 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}`);
|
||||
}
|
||||
334
docs/api-access/API_ACCESS.md
Normal file
334
docs/api-access/API_ACCESS.md
Normal 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)*
|
||||
Loading…
Reference in New Issue
Block a user