Compare commits

...

7 Commits

Author SHA1 Message Date
David
53f3be9741 Merge branch 'dev' into preprod
All checks were successful
CD Preprod / Backend — Integration Tests (push) Successful in 9m56s
CD Preprod / Build Backend (push) Successful in 13m53s
CD Preprod / Build Log Exporter (push) Successful in 36s
CD Preprod / Build Frontend (push) Successful in 42m59s
CD Preprod / Deploy to Preprod (push) Successful in 24s
CD Preprod / Notify Failure (push) Has been skipped
CD Preprod / Notify Success (push) Successful in 1s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 11m4s
CD Preprod / Backend — Lint (push) Successful in 10m20s
CD Preprod / Backend — Unit Tests (push) Successful in 10m7s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m41s
2026-06-17 23:40:42 +02:00
David
8f1afb0098 Merge branch 'split_dashbaord_admin_user' into dev
All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m19s
Dev CI / Frontend — Unit Tests (push) Successful in 10m39s
Dev CI / Notify Failure (push) Has been skipped
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m7s
Dev CI / Backend — Unit Tests (push) Successful in 10m9s
2026-06-17 22:50:20 +02:00
David
6b9ad95811 fix logs 2026-06-13 12:17:06 +02:00
David
5aed7d81ce fix mdp see 2026-06-13 12:01:19 +02:00
David
c6eaaf354a fix admin page 2026-06-13 11:53:27 +02:00
David
bd52819f28 fix admin page 2026-06-13 11:53:17 +02:00
David
9e8f157d70 fix security 2026-06-12 11:33:37 +02:00
54 changed files with 3432 additions and 3160 deletions

View File

@ -18,11 +18,19 @@ REDIS_PORT=6379
REDIS_PASSWORD=xpeditis_redis_password REDIS_PASSWORD=xpeditis_redis_password
REDIS_DB=0 REDIS_DB=0
# JWT # JWT (JWT_SECRET must be at least 32 characters)
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
JWT_ACCESS_EXPIRATION=15m JWT_ACCESS_EXPIRATION=15m
JWT_REFRESH_EXPIRATION=7d JWT_REFRESH_EXPIRATION=7d
# Auth cookies — domain shared between frontend and API in production
# (e.g. .xpeditis.com). Leave unset for localhost development.
# COOKIE_DOMAIN=.xpeditis.com
# Secret used to derive carrier document passwords (min 16 chars).
# Falls back to JWT_SECRET when unset.
# DOCUMENT_PASSWORD_SECRET=your-document-password-secret
# OAuth2 - Google # OAuth2 - Google
GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret GOOGLE_CLIENT_SECRET=your-google-client-secret

View File

@ -74,7 +74,7 @@ EXPOSE 4000
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:4000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))" CMD node -e "require('http').get('http://localhost:4000/api/v1/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
# Set environment variables # Set environment variables
ENV NODE_ENV=production \ ENV NODE_ENV=production \

File diff suppressed because it is too large Load Diff

View File

@ -51,6 +51,7 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"compression": "^1.8.1", "compression": "^1.8.1",
"cookie-parser": "^1.4.7",
"csv-parse": "^6.1.0", "csv-parse": "^6.1.0",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
@ -87,6 +88,7 @@
"@nestjs/testing": "^10.2.10", "@nestjs/testing": "^10.2.10",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/compression": "^1.8.1", "@types/compression": "^1.8.1",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jest": "^29.5.11", "@types/jest": "^29.5.11",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",

View File

@ -39,6 +39,7 @@ import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.mod
// Import global guards // Import global guards
import { ApiKeyOrJwtGuard } from './application/guards/api-key-or-jwt.guard'; import { ApiKeyOrJwtGuard } from './application/guards/api-key-or-jwt.guard';
import { HealthController } from './application/controllers/health.controller';
import { CustomThrottlerGuard } from './application/guards/throttle.guard'; import { CustomThrottlerGuard } from './application/guards/throttle.guard';
@Module({ @Module({
@ -59,9 +60,14 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
REDIS_HOST: Joi.string().required(), REDIS_HOST: Joi.string().required(),
REDIS_PORT: Joi.number().default(6379), REDIS_PORT: Joi.number().default(6379),
REDIS_PASSWORD: Joi.string().required(), REDIS_PASSWORD: Joi.string().required(),
JWT_SECRET: Joi.string().required(), JWT_SECRET: Joi.string().min(32).required(),
JWT_ACCESS_EXPIRATION: Joi.string().default('15m'), JWT_ACCESS_EXPIRATION: Joi.string().default('15m'),
JWT_REFRESH_EXPIRATION: Joi.string().default('7d'), JWT_REFRESH_EXPIRATION: Joi.string().default('7d'),
// Cookie domain for auth cookies (e.g. ".xpeditis.com" so the frontend
// and API subdomains share them). Unset = host-only (fine for localhost).
COOKIE_DOMAIN: Joi.string().optional(),
// Secret used to derive carrier document passwords (falls back to JWT_SECRET)
DOCUMENT_PASSWORD_SECRET: Joi.string().min(16).optional(),
// SMTP Configuration // SMTP Configuration
SMTP_HOST: Joi.string().required(), SMTP_HOST: Joi.string().required(),
SMTP_PORT: Joi.number().default(2525), SMTP_PORT: Joi.number().default(2525),
@ -185,7 +191,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
ApiKeysModule, ApiKeysModule,
LogsModule, LogsModule,
], ],
controllers: [], controllers: [HealthController],
providers: [ providers: [
// Global authentication guard — supports both JWT (frontend) and API key (Gold/Platinium) // Global authentication guard — supports both JWT (frontend) and API key (Gold/Platinium)
// All routes are protected by default, use @Public() to bypass // All routes are protected by default, use @Public() to bypass

View File

@ -22,6 +22,7 @@ import { InvitationService } from '../services/invitation.service';
import { InvitationsController } from '../controllers/invitations.controller'; import { InvitationsController } from '../controllers/invitations.controller';
import { EmailModule } from '../../infrastructure/email/email.module'; import { EmailModule } from '../../infrastructure/email/email.module';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
import { AuditModule } from '../audit/audit.module';
@Module({ @Module({
imports: [ imports: [
@ -53,6 +54,9 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
// Subscriptions module for license checks // Subscriptions module for license checks
SubscriptionsModule, SubscriptionsModule,
// Audit module for login/logout tracking
AuditModule,
], ],
controllers: [AuthController, InvitationsController], controllers: [AuthController, InvitationsController],
providers: [ providers: [

View File

@ -21,6 +21,7 @@ import {
} from '@domain/ports/out/organization.repository'; } from '@domain/ports/out/organization.repository';
import { Organization } from '@domain/entities/organization.entity'; import { Organization } from '@domain/entities/organization.entity';
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
import { CachePort, CACHE_PORT } from '@domain/ports/out/cache.port';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { RegisterOrganizationDto } from '../dto/auth-login.dto'; import { RegisterOrganizationDto } from '../dto/auth-login.dto';
import { SubscriptionService } from '../services/subscription.service'; import { SubscriptionService } from '../services/subscription.service';
@ -34,6 +35,7 @@ export interface JwtPayload {
plan?: string; // subscription plan (BRONZE, SILVER, GOLD, PLATINIUM) plan?: string; // subscription plan (BRONZE, SILVER, GOLD, PLATINIUM)
planFeatures?: string[]; // plan feature flags planFeatures?: string[]; // plan feature flags
type: 'access' | 'refresh'; type: 'access' | 'refresh';
rememberMe?: boolean; // drives auth cookie persistence across refreshes
} }
@Injectable() @Injectable()
@ -47,6 +49,8 @@ export class AuthService {
private readonly organizationRepository: OrganizationRepository, private readonly organizationRepository: OrganizationRepository,
@Inject(EMAIL_PORT) @Inject(EMAIL_PORT)
private readonly emailService: EmailPort, private readonly emailService: EmailPort,
@Inject(CACHE_PORT)
private readonly cache: CachePort,
@InjectRepository(PasswordResetTokenOrmEntity) @InjectRepository(PasswordResetTokenOrmEntity)
private readonly passwordResetTokenRepository: Repository<PasswordResetTokenOrmEntity>, private readonly passwordResetTokenRepository: Repository<PasswordResetTokenOrmEntity>,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
@ -93,6 +97,11 @@ export class AuthService {
// - Otherwise, default to USER // - Otherwise, default to USER
let userRole: UserRole; let userRole: UserRole;
if (invitationRole) { if (invitationRole) {
// Invitations can only grant non-admin roles — reject anything else
const allowedInvitationRoles: UserRole[] = [UserRole.MANAGER, UserRole.USER, UserRole.VIEWER];
if (!allowedInvitationRoles.includes(invitationRole as UserRole)) {
throw new BadRequestException('Invalid invitation role');
}
userRole = invitationRole as UserRole; userRole = invitationRole as UserRole;
} else if (organizationData) { } else if (organizationData) {
// User creating a new organization becomes MANAGER // User creating a new organization becomes MANAGER
@ -146,7 +155,8 @@ export class AuthService {
*/ */
async login( async login(
email: string, email: string,
password: string password: string,
rememberMe = false
): Promise<{ accessToken: string; refreshToken: string; user: any }> { ): Promise<{ accessToken: string; refreshToken: string; user: any }> {
this.logger.log(`Login attempt for: ${email}`); this.logger.log(`Login attempt for: ${email}`);
@ -166,7 +176,7 @@ export class AuthService {
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException('Invalid credentials');
} }
const tokens = await this.generateTokens(user); const tokens = await this.generateTokens(user, rememberMe);
this.logger.log(`User logged in successfully: ${email}`); this.logger.log(`User logged in successfully: ${email}`);
@ -188,7 +198,7 @@ export class AuthService {
*/ */
async refreshAccessToken( async refreshAccessToken(
refreshToken: string refreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> { ): Promise<{ accessToken: string; refreshToken: string; rememberMe: boolean }> {
try { try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, { const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
secret: this.configService.get('JWT_SECRET'), secret: this.configService.get('JWT_SECRET'),
@ -198,23 +208,78 @@ export class AuthService {
throw new UnauthorizedException('Invalid token type'); throw new UnauthorizedException('Invalid token type');
} }
if (await this.isRefreshTokenRevoked(refreshToken)) {
throw new UnauthorizedException('Refresh token has been revoked');
}
const user = await this.userRepository.findById(payload.sub); const user = await this.userRepository.findById(payload.sub);
if (!user || !user.isActive) { if (!user || !user.isActive) {
throw new UnauthorizedException('User not found or inactive'); throw new UnauthorizedException('User not found or inactive');
} }
const tokens = await this.generateTokens(user); const rememberMe = payload.rememberMe === true;
const tokens = await this.generateTokens(user, rememberMe);
this.logger.log(`Access token refreshed for user: ${user.email}`); this.logger.log(`Access token refreshed for user: ${user.email}`);
return tokens; return { ...tokens, rememberMe };
} catch (error: any) { } catch (error: any) {
this.logger.error(`Token refresh failed: ${error?.message || 'Unknown error'}`); this.logger.error(`Token refresh failed: ${error?.message || 'Unknown error'}`);
throw new UnauthorizedException('Invalid or expired refresh token'); throw new UnauthorizedException('Invalid or expired refresh token');
} }
} }
/**
* Logout revoke the refresh token so it can no longer be used.
* The revocation list lives in Redis with a TTL matching the token's
* remaining lifetime, so entries clean themselves up.
*/
async logout(
refreshToken?: string
): Promise<{ userId: string; email: string; organizationId: string } | null> {
if (!refreshToken) {
return null;
}
try {
const payload = this.jwtService.decode(refreshToken) as JwtPayload & { exp?: number };
const remainingSeconds = payload?.exp
? Math.max(payload.exp - Math.floor(Date.now() / 1000), 1)
: 7 * 24 * 60 * 60;
await this.cache.set(this.revokedTokenKey(refreshToken), true, remainingSeconds);
this.logger.log(`Refresh token revoked for user: ${payload?.email ?? 'unknown'}`);
if (payload?.sub) {
return {
userId: payload.sub,
email: payload.email,
organizationId: payload.organizationId,
};
}
return null;
} catch (error) {
// Never block logout on revocation failures — log and continue
this.logger.error(`Failed to revoke refresh token: ${error}`);
return null;
}
}
private async isRefreshTokenRevoked(refreshToken: string): Promise<boolean> {
try {
return (await this.cache.get<boolean>(this.revokedTokenKey(refreshToken))) === true;
} catch (error) {
this.logger.error(`Failed to check refresh token revocation: ${error}`);
return false;
}
}
private revokedTokenKey(refreshToken: string): string {
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
return `auth:revoked-refresh:${hash}`;
}
/** /**
* Initiate password reset generates token and sends email * Initiate password reset generates token and sends email
*/ */
@ -234,13 +299,15 @@ export class AuthService {
{ usedAt: new Date() } { usedAt: new Date() }
); );
// Generate a secure random token // Generate a secure random token; only its hash is stored so a database
// leak cannot be used to take over accounts via pending reset tokens
const token = crypto.randomBytes(32).toString('hex'); const token = crypto.randomBytes(32).toString('hex');
const tokenHash = this.hashResetToken(token);
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await this.passwordResetTokenRepository.save({ await this.passwordResetTokenRepository.save({
userId: user.id, userId: user.id,
token, token: tokenHash,
expiresAt, expiresAt,
usedAt: null, usedAt: null,
}); });
@ -254,7 +321,9 @@ export class AuthService {
* Reset password using token from email * Reset password using token from email
*/ */
async resetPassword(token: string, newPassword: string): Promise<void> { async resetPassword(token: string, newPassword: string): Promise<void> {
const resetToken = await this.passwordResetTokenRepository.findOne({ where: { token } }); const resetToken = await this.passwordResetTokenRepository.findOne({
where: { token: this.hashResetToken(token) },
});
if (!resetToken) { if (!resetToken) {
throw new BadRequestException('Token de réinitialisation invalide ou expiré'); throw new BadRequestException('Token de réinitialisation invalide ou expiré');
@ -293,6 +362,10 @@ export class AuthService {
this.logger.log(`Password reset successfully for user: ${user.email}`); this.logger.log(`Password reset successfully for user: ${user.email}`);
} }
private hashResetToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
/** /**
* Validate user from JWT payload * Validate user from JWT payload
*/ */
@ -309,7 +382,10 @@ export class AuthService {
/** /**
* Generate access and refresh tokens * Generate access and refresh tokens
*/ */
private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> { private async generateTokens(
user: User,
rememberMe = false
): Promise<{ accessToken: string; refreshToken: string }> {
// ADMIN users always get PLATINIUM plan with no expiration // ADMIN users always get PLATINIUM plan with no expiration
let plan = 'BRONZE'; let plan = 'BRONZE';
let planFeatures: string[] = []; let planFeatures: string[] = [];
@ -355,6 +431,7 @@ export class AuthService {
plan, plan,
planFeatures, planFeatures,
type: 'refresh', type: 'refresh',
rememberMe,
}; };
const [accessToken, refreshToken] = await Promise.all([ const [accessToken, refreshToken] = await Promise.all([

View File

@ -35,7 +35,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
private readonly authService: AuthService private readonly authService: AuthService
) { ) {
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromExtractors([
ExtractJwt.fromAuthHeaderAsBearerToken(),
// httpOnly cookie set by the auth endpoints (XSS-safe storage)
(req: { cookies?: Record<string, string> }) => req?.cookies?.accessToken ?? null,
]),
ignoreExpiration: false, ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET'), secretOrKey: configService.get<string>('JWT_SECRET'),
}); });

View File

@ -8,10 +8,15 @@ import {
Get, Get,
Inject, Inject,
NotFoundException, NotFoundException,
UnauthorizedException,
InternalServerErrorException, InternalServerErrorException,
Logger, Logger,
Req,
Res,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Request, Response } from 'express';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { import {
LoginDto, LoginDto,
@ -29,6 +34,26 @@ import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { UserMapper } from '../mappers/user.mapper'; import { UserMapper } from '../mappers/user.mapper';
import { InvitationService } from '../services/invitation.service'; import { InvitationService } from '../services/invitation.service';
import { AuditService } from '../services/audit.service';
import { AuditAction } from '@domain/entities/audit-log.entity';
import {
AUTH_COOKIE_NAMES,
authCookieOptions,
} from '../../infrastructure/security/security.config';
const REFRESH_COOKIE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
/**
* Escape user-provided text before interpolating it into HTML emails
*/
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** /**
* Authentication Controller * Authentication Controller
@ -49,9 +74,53 @@ export class AuthController {
private readonly authService: AuthService, private readonly authService: AuthService,
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
private readonly invitationService: InvitationService, private readonly invitationService: InvitationService,
private readonly auditService: AuditService,
@Inject(EMAIL_PORT) private readonly emailService: EmailPort @Inject(EMAIL_PORT) private readonly emailService: EmailPort
) {} ) {}
/**
* Extract the client IP and user agent from the request so we can record
* *who* connected and *from where* in the audit trail.
*/
private getClientInfo(req: Request): { ipAddress?: string; userAgent?: string } {
const forwardedFor = req.headers['x-forwarded-for'];
const ipAddress =
(Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor?.split(',')[0]?.trim()) ||
req.ip ||
req.socket?.remoteAddress;
return {
ipAddress,
userAgent: req.headers['user-agent'],
};
}
/**
* Deliver tokens as httpOnly cookies so they are out of reach of XSS.
* When rememberMe is false the cookies are session-scoped (cleared when
* the browser closes); otherwise they persist for the refresh window.
*/
private setAuthCookies(
res: Response,
tokens: { accessToken: string; refreshToken: string },
rememberMe: boolean
): void {
const maxAgeMs = rememberMe ? REFRESH_COOKIE_MAX_AGE_MS : undefined;
res.cookie(AUTH_COOKIE_NAMES.accessToken, tokens.accessToken, authCookieOptions({ maxAgeMs }));
res.cookie(
AUTH_COOKIE_NAMES.refreshToken,
tokens.refreshToken,
authCookieOptions({ maxAgeMs })
);
// Readable flag (no token inside) so the frontend knows a session exists
res.cookie(AUTH_COOKIE_NAMES.session, '1', authCookieOptions({ maxAgeMs, httpOnly: false }));
}
private clearAuthCookies(res: Response): void {
res.clearCookie(AUTH_COOKIE_NAMES.accessToken, authCookieOptions());
res.clearCookie(AUTH_COOKIE_NAMES.refreshToken, authCookieOptions());
res.clearCookie(AUTH_COOKIE_NAMES.session, authCookieOptions({ httpOnly: false }));
}
/** /**
* Register a new user * Register a new user
* *
@ -61,6 +130,7 @@ export class AuthController {
* @returns Access token, refresh token, and user info * @returns Access token, refresh token, and user info
*/ */
@Public() @Public()
@Throttle({ default: { limit: 5, ttl: 60000 } })
@Post('register') @Post('register')
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@ApiOperation({ @ApiOperation({
@ -80,7 +150,10 @@ export class AuthController {
status: 400, status: 400,
description: 'Validation error (invalid email, weak password, etc.)', description: 'Validation error (invalid email, weak password, etc.)',
}) })
async register(@Body() dto: RegisterDto): Promise<AuthResponseDto> { async register(
@Body() dto: RegisterDto,
@Res({ passthrough: true }) res: Response
): Promise<AuthResponseDto> {
// If invitation token is provided, verify and use it // If invitation token is provided, verify and use it
let invitationOrganizationId: string | undefined; let invitationOrganizationId: string | undefined;
let invitationRole: string | undefined; let invitationRole: string | undefined;
@ -101,12 +174,14 @@ export class AuthController {
dto.lastName = dto.lastName || invitation.lastName; dto.lastName = dto.lastName || invitation.lastName;
} }
// Joining an existing organization is only allowed through a verified
// invitation token — never from a caller-supplied organization ID.
const result = await this.authService.register( const result = await this.authService.register(
dto.email, dto.email,
dto.password, dto.password,
dto.firstName, dto.firstName,
dto.lastName, dto.lastName,
invitationOrganizationId || dto.organizationId, invitationOrganizationId,
dto.organization, dto.organization,
invitationRole invitationRole
); );
@ -116,6 +191,8 @@ export class AuthController {
await this.invitationService.markInvitationAsUsed(dto.invitationToken); await this.invitationService.markInvitationAsUsed(dto.invitationToken);
} }
this.setAuthCookies(res, result, false);
return { return {
accessToken: result.accessToken, accessToken: result.accessToken,
refreshToken: result.refreshToken, refreshToken: result.refreshToken,
@ -132,6 +209,7 @@ export class AuthController {
* @returns Access token, refresh token, and user info * @returns Access token, refresh token, and user info
*/ */
@Public() @Public()
@Throttle({ default: { limit: 5, ttl: 60000 } })
@Post('login') @Post('login')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({
@ -147,14 +225,61 @@ export class AuthController {
status: 401, status: 401,
description: 'Invalid credentials or inactive account', description: 'Invalid credentials or inactive account',
}) })
async login(@Body() dto: LoginDto): Promise<AuthResponseDto> { async login(
const result = await this.authService.login(dto.email, dto.password); @Body() dto: LoginDto,
@Req() req: Request,
@Res({ passthrough: true }) res: Response
): Promise<AuthResponseDto> {
const rememberMe = dto.rememberMe === true;
const { ipAddress, userAgent } = this.getClientInfo(req);
try {
const result = await this.authService.login(dto.email, dto.password, rememberMe);
this.setAuthCookies(res, result, rememberMe);
// Audit log: record who logged in, when and from where
await this.auditService.logSuccess(
AuditAction.USER_LOGIN,
result.user.id,
result.user.email,
result.user.organizationId,
{
resourceType: 'user',
resourceId: result.user.id,
ipAddress,
userAgent,
metadata: { rememberMe },
}
);
this.logger.log(`Login success: ${result.user.email} from ${ipAddress ?? 'unknown IP'}`);
return { return {
accessToken: result.accessToken, accessToken: result.accessToken,
refreshToken: result.refreshToken, refreshToken: result.refreshToken,
user: result.user, user: result.user,
}; };
} catch (error: any) {
// Audit log: record failed login attempts (the attempted email is the
// only identity we have — the credentials did not match a valid user)
await this.auditService.logFailure(
AuditAction.USER_LOGIN,
'unknown',
dto.email,
'unknown',
error?.message || 'Invalid credentials',
{
resourceType: 'user',
ipAddress,
userAgent,
}
);
this.logger.warn(`Login failed for ${dto.email} from ${ipAddress ?? 'unknown IP'}`);
throw error;
}
} }
/** /**
@ -166,6 +291,7 @@ export class AuthController {
* @returns New access token * @returns New access token
*/ */
@Public() @Public()
@Throttle({ default: { limit: 20, ttl: 60000 } })
@Post('refresh') @Post('refresh')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({
@ -175,10 +301,10 @@ export class AuthController {
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Token refreshed successfully', description: 'Token refreshed successfully — new tokens are set as httpOnly cookies',
schema: { schema: {
properties: { properties: {
accessToken: { type: 'string', example: 'eyJhbGciOiJIUzI1NiIs...' }, success: { type: 'boolean', example: true },
}, },
}, },
}) })
@ -186,27 +312,40 @@ export class AuthController {
status: 401, status: 401,
description: 'Invalid or expired refresh token', description: 'Invalid or expired refresh token',
}) })
async refresh(@Body() dto: RefreshTokenDto): Promise<{ accessToken: string }> { async refresh(
const result = await this.authService.refreshAccessToken(dto.refreshToken); @Body() dto: RefreshTokenDto,
@Req() req: Request,
@Res({ passthrough: true }) res: Response
): Promise<{ success: boolean }> {
// Prefer the httpOnly cookie; fall back to the body for legacy clients
const refreshToken = req.cookies?.[AUTH_COOKIE_NAMES.refreshToken] || dto.refreshToken;
return { accessToken: result.accessToken }; if (!refreshToken) {
this.clearAuthCookies(res);
throw new UnauthorizedException('No refresh token provided');
}
const result = await this.authService.refreshAccessToken(refreshToken);
this.setAuthCookies(res, result, result.rememberMe);
// Tokens are intentionally NOT returned in the body: an XSS payload could
// otherwise call this endpoint and exfiltrate a fresh access token.
return { success: true };
} }
/** /**
* Logout (placeholder) * Logout
* *
* Currently a no-op endpoint. With JWT, logout is typically handled client-side * Revokes the refresh token (Redis blacklist) and clears the auth cookies.
* by removing tokens. For more security, implement token blacklisting with Redis. * The access token naturally expires within 15 minutes.
*
* @returns Success message
*/ */
@UseGuards(JwtAuthGuard) @Public()
@Post('logout') @Post('logout')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ @ApiOperation({
summary: 'Logout', summary: 'Logout',
description: 'Logout the current user. Currently handled client-side by removing tokens.', description: 'Revoke the refresh token and clear authentication cookies.',
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@ -217,9 +356,28 @@ export class AuthController {
}, },
}, },
}) })
async logout(): Promise<{ message: string }> { async logout(
// TODO: Implement token blacklisting with Redis for more security @Req() req: Request,
// For now, logout is handled client-side by removing tokens @Res({ passthrough: true }) res: Response
): Promise<{ message: string }> {
const refreshToken = req.cookies?.[AUTH_COOKIE_NAMES.refreshToken];
const loggedOutUser = await this.authService.logout(refreshToken);
this.clearAuthCookies(res);
// Audit log: record who logged out and when
if (loggedOutUser) {
const { ipAddress, userAgent } = this.getClientInfo(req);
await this.auditService.logSuccess(
AuditAction.USER_LOGOUT,
loggedOutUser.userId,
loggedOutUser.email,
loggedOutUser.organizationId,
{ resourceType: 'user', resourceId: loggedOutUser.userId, ipAddress, userAgent }
);
this.logger.log(`Logout: ${loggedOutUser.email} from ${ipAddress ?? 'unknown IP'}`);
}
return { message: 'Logout successful' }; return { message: 'Logout successful' };
} }
@ -227,6 +385,7 @@ export class AuthController {
* Contact form forwards message to contact@xpeditis.com * Contact form forwards message to contact@xpeditis.com
*/ */
@Public() @Public()
@Throttle({ default: { limit: 3, ttl: 60000 } })
@Post('contact') @Post('contact')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({
@ -245,7 +404,13 @@ export class AuthController {
other: 'Autre', other: 'Autre',
}; };
const subjectLabel = subjectLabels[dto.subject] || dto.subject; const subjectLabel = escapeHtml(subjectLabels[dto.subject] || dto.subject);
const firstName = escapeHtml(dto.firstName);
const lastName = escapeHtml(dto.lastName);
const email = escapeHtml(dto.email);
const company = dto.company ? escapeHtml(dto.company) : undefined;
const phone = dto.phone ? escapeHtml(dto.phone) : undefined;
const message = escapeHtml(dto.message);
const html = ` const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
@ -256,14 +421,14 @@ export class AuthController {
<table style="width: 100%; border-collapse: collapse;"> <table style="width: 100%; border-collapse: collapse;">
<tr> <tr>
<td style="padding: 8px 0; color: #666; width: 130px; font-size: 14px;">Nom</td> <td style="padding: 8px 0; color: #666; width: 130px; font-size: 14px;">Nom</td>
<td style="padding: 8px 0; color: #222; font-weight: bold; font-size: 14px;">${dto.firstName} ${dto.lastName}</td> <td style="padding: 8px 0; color: #222; font-weight: bold; font-size: 14px;">${firstName} ${lastName}</td>
</tr> </tr>
<tr> <tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Email</td> <td style="padding: 8px 0; color: #666; font-size: 14px;">Email</td>
<td style="padding: 8px 0; font-size: 14px;"><a href="mailto:${dto.email}" style="color: #34CCCD;">${dto.email}</a></td> <td style="padding: 8px 0; font-size: 14px;"><a href="mailto:${email}" style="color: #34CCCD;">${email}</a></td>
</tr> </tr>
${dto.company ? `<tr><td style="padding: 8px 0; color: #666; font-size: 14px;">Entreprise</td><td style="padding: 8px 0; color: #222; font-size: 14px;">${dto.company}</td></tr>` : ''} ${company ? `<tr><td style="padding: 8px 0; color: #666; font-size: 14px;">Entreprise</td><td style="padding: 8px 0; color: #222; font-size: 14px;">${company}</td></tr>` : ''}
${dto.phone ? `<tr><td style="padding: 8px 0; color: #666; font-size: 14px;">Téléphone</td><td style="padding: 8px 0; color: #222; font-size: 14px;">${dto.phone}</td></tr>` : ''} ${phone ? `<tr><td style="padding: 8px 0; color: #666; font-size: 14px;">Téléphone</td><td style="padding: 8px 0; color: #222; font-size: 14px;">${phone}</td></tr>` : ''}
<tr> <tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Sujet</td> <td style="padding: 8px 0; color: #666; font-size: 14px;">Sujet</td>
<td style="padding: 8px 0; color: #222; font-size: 14px;">${subjectLabel}</td> <td style="padding: 8px 0; color: #222; font-size: 14px;">${subjectLabel}</td>
@ -271,7 +436,7 @@ export class AuthController {
</table> </table>
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid #ddd;"> <div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid #ddd;">
<p style="color: #666; font-size: 14px; margin: 0 0 8px 0;">Message :</p> <p style="color: #666; font-size: 14px; margin: 0 0 8px 0;">Message :</p>
<p style="color: #222; font-size: 14px; white-space: pre-wrap; margin: 0;">${dto.message}</p> <p style="color: #222; font-size: 14px; white-space: pre-wrap; margin: 0;">${message}</p>
</div> </div>
</div> </div>
<div style="background: #f0f0f0; padding: 12px 24px; border-radius: 0 0 8px 8px; text-align: center;"> <div style="background: #f0f0f0; padding: 12px 24px; border-radius: 0 0 8px 8px; text-align: center;">
@ -284,7 +449,7 @@ export class AuthController {
await this.emailService.send({ await this.emailService.send({
to: 'contact@xpeditis.com', to: 'contact@xpeditis.com',
replyTo: dto.email, replyTo: dto.email,
subject: `[Contact] ${subjectLabel}${dto.firstName} ${dto.lastName}`, subject: `[Contact] ${subjectLabels[dto.subject] || dto.subject}${dto.firstName} ${dto.lastName}`,
html, html,
}); });
} catch (error) { } catch (error) {
@ -301,6 +466,7 @@ export class AuthController {
* Forgot password sends reset email * Forgot password sends reset email
*/ */
@Public() @Public()
@Throttle({ default: { limit: 3, ttl: 60000 } })
@Post('forgot-password') @Post('forgot-password')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({
@ -319,6 +485,7 @@ export class AuthController {
* Reset password using token from email * Reset password using token from email
*/ */
@Public() @Public()
@Throttle({ default: { limit: 5, ttl: 60000 } })
@Post('reset-password') @Post('reset-password')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({

View File

@ -248,18 +248,7 @@ export class RegisterDto {
invitationToken?: string; invitationToken?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
example: '550e8400-e29b-41d4-a716-446655440000', description: 'Organization data (required if invitationToken is not provided)',
description:
'Organization ID (optional - for invited users). If not provided, organization data must be provided.',
required: false,
})
@IsString()
@IsOptional()
organizationId?: string;
@ApiPropertyOptional({
description:
'Organization data (required if organizationId and invitationToken are not provided)',
type: RegisterOrganizationDto, type: RegisterOrganizationDto,
required: false, required: false,
}) })
@ -304,10 +293,11 @@ export class AuthResponseDto {
} }
export class RefreshTokenDto { export class RefreshTokenDto {
@ApiProperty({ @ApiPropertyOptional({
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
description: 'Refresh token', description: 'Refresh token (optional — the httpOnly cookie is preferred)',
}) })
@IsString() @IsString()
refreshToken: string; @IsOptional()
refreshToken?: string;
} }

View File

@ -8,6 +8,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import * as crypto from 'crypto';
import { CsvBooking, CsvBookingStatus, DocumentType } from '@domain/entities/csv-booking.entity'; import { CsvBooking, CsvBookingStatus, DocumentType } from '@domain/entities/csv-booking.entity';
import { PortCode } from '@domain/value-objects/port-code.vo'; import { PortCode } from '@domain/value-objects/port-code.vo';
import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository'; import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
@ -81,17 +82,54 @@ export class CsvBookingService {
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // No 0, O, 1, I for clarity const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // No 0, O, 1, I for clarity
let code = ''; let code = '';
const randomBytes = crypto.randomBytes(6);
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length)); code += chars.charAt(randomBytes[i] % chars.length);
} }
return `XPD-${year}-${code}`; return `XPD-${year}-${code}`;
} }
/** /**
* Extract the password from booking number (last 6 characters) * Derive the document-access password for a booking.
*
* The password is an HMAC of the booking ID keyed with a server-side secret,
* so it can be re-computed when (re)sending carrier emails but cannot be
* guessed from any data visible to third parties (unlike the previous
* scheme, which reused the last 6 characters of the booking number).
*/ */
private extractPasswordFromBookingNumber(bookingNumber: string): string { private deriveDocumentPassword(bookingId: string): string {
return bookingNumber.split('-').pop() || bookingNumber.slice(-6); const secret = process.env.DOCUMENT_PASSWORD_SECRET || process.env.JWT_SECRET || '';
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // No 0, O, 1, I for clarity
const digest = crypto.createHmac('sha256', secret).update(`doc-password:${bookingId}`).digest();
let password = '';
for (let i = 0; i < 8; i++) {
password += chars.charAt(digest[i] % chars.length);
}
return password;
}
/**
* Re-sync the stored password hash with the derived password and return the
* plaintext for inclusion in the carrier email. Self-heals bookings created
* before the HMAC-based scheme.
*/
private async syncDocumentPassword(bookingId: string): Promise<string> {
const password = this.deriveDocumentPassword(bookingId);
const ormBooking = await this.csvBookingRepository['repository'].findOne({
where: { id: bookingId },
});
if (ormBooking) {
const matches = ormBooking.passwordHash
? await argon2.verify(ormBooking.passwordHash, password).catch(() => false)
: false;
if (!matches) {
ormBooking.passwordHash = await argon2.hash(password);
await this.csvBookingRepository['repository'].save(ormBooking);
}
}
return password;
} }
/** /**
@ -114,7 +152,7 @@ export class CsvBookingService {
const confirmationToken = uuidv4(); const confirmationToken = uuidv4();
const bookingId = uuidv4(); const bookingId = uuidv4();
const bookingNumber = this.generateBookingNumber(); const bookingNumber = this.generateBookingNumber();
const documentPassword = this.extractPasswordFromBookingNumber(bookingNumber); const documentPassword = this.deriveDocumentPassword(bookingId);
// Hash the password for storage // Hash the password for storage
const passwordHash = await argon2.hash(documentPassword); const passwordHash = await argon2.hash(documentPassword);
@ -292,9 +330,7 @@ export class CsvBookingService {
where: { id: bookingId }, where: { id: bookingId },
}); });
const bookingNumber = ormBooking?.bookingNumber; const bookingNumber = ormBooking?.bookingNumber;
const documentPassword = bookingNumber const documentPassword = await this.syncDocumentPassword(booking.id);
? this.extractPasswordFromBookingNumber(bookingNumber)
: undefined;
// NOW send email to carrier // NOW send email to carrier
try { try {
@ -464,9 +500,7 @@ export class CsvBookingService {
where: { id: bookingId }, where: { id: bookingId },
}); });
const bookingNumber = ormBooking?.bookingNumber; const bookingNumber = ormBooking?.bookingNumber;
const documentPassword = bookingNumber const documentPassword = await this.syncDocumentPassword(booking.id);
? this.extractPasswordFromBookingNumber(bookingNumber)
: undefined;
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, { await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
bookingId: booking.id, bookingId: booking.id,
@ -521,9 +555,7 @@ export class CsvBookingService {
where: { id: bookingId }, where: { id: bookingId },
}); });
const bookingNumber = ormBooking?.bookingNumber; const bookingNumber = ormBooking?.bookingNumber;
const documentPassword = bookingNumber const documentPassword = await this.syncDocumentPassword(booking.id);
? this.extractPasswordFromBookingNumber(bookingNumber)
: undefined;
// Send email to carrier // Send email to carrier
try { try {
@ -784,9 +816,7 @@ export class CsvBookingService {
// Extract password from booking number for the email // Extract password from booking number for the email
const bookingNumber = ormBooking?.bookingNumber; const bookingNumber = ormBooking?.bookingNumber;
const documentPassword = bookingNumber const documentPassword = await this.syncDocumentPassword(booking.id);
? this.extractPasswordFromBookingNumber(bookingNumber)
: undefined;
// Send document access email to carrier // Send document access email to carrier
try { try {

View File

@ -107,21 +107,6 @@ export const corsConfig = {
maxAge: 86400, // 24 hours maxAge: 86400, // 24 hours
}; };
/**
* Session Configuration
*/
export const sessionConfig = {
secret: process.env.SESSION_SECRET || 'change-this-secret',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'strict' as const,
maxAge: 7200000, // 2 hours
},
};
/** /**
* Password Policy * Password Policy
*/ */
@ -155,19 +140,36 @@ export const fileUploadConfig = {
}; };
/** /**
* JWT Configuration * Auth Cookie Configuration
*
* Tokens are delivered as httpOnly cookies so they are not readable from
* JavaScript (XSS mitigation). COOKIE_DOMAIN must be set in production when
* the API and the frontend live on different subdomains (e.g. ".xpeditis.com").
*/ */
export const jwtConfig = { export const AUTH_COOKIE_NAMES = {
accessToken: { accessToken: 'accessToken',
secret: process.env.JWT_SECRET || 'change-this-secret', refreshToken: 'refreshToken',
expiresIn: '15m', // 15 minutes /** Non-httpOnly flag the frontend reads to know a session exists (contains no token) */
}, session: 'xpeditis_session',
refreshToken: { } as const;
secret: process.env.JWT_REFRESH_SECRET || 'change-this-refresh-secret',
expiresIn: '7d', // 7 days export function authCookieOptions(options?: { maxAgeMs?: number; httpOnly?: boolean }): {
}, httpOnly: boolean;
algorithm: 'HS256' as const, secure: boolean;
sameSite: 'lax';
path: string;
domain?: string;
maxAge?: number;
} {
return {
httpOnly: options?.httpOnly ?? true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
...(process.env.COOKIE_DOMAIN ? { domain: process.env.COOKIE_DOMAIN } : {}),
...(options?.maxAgeMs ? { maxAge: options.maxAgeMs } : {}),
}; };
}
/** /**
* Brute Force Protection * Brute Force Protection

View File

@ -5,6 +5,7 @@ import { ConfigService } from '@nestjs/config';
import { I18nService, I18nValidationExceptionFilter, I18nValidationPipe } from 'nestjs-i18n'; import { I18nService, I18nValidationExceptionFilter, I18nValidationPipe } from 'nestjs-i18n';
import helmet from 'helmet'; import helmet from 'helmet';
import compression from 'compression'; import compression from 'compression';
import cookieParser from 'cookie-parser';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { Logger } from 'nestjs-pino'; import { Logger } from 'nestjs-pino';
import { helmetConfig, corsConfig } from './infrastructure/security/security.config'; import { helmetConfig, corsConfig } from './infrastructure/security/security.config';
@ -33,6 +34,9 @@ async function bootstrap() {
// Compression for API responses // Compression for API responses
app.use(compression()); app.use(compression());
// Parse cookies (httpOnly auth cookies)
app.use(cookieParser());
// CORS with strict configuration // CORS with strict configuration
app.enableCors(corsConfig); app.enableCors(corsConfig);

View File

@ -0,0 +1,211 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useAuth } from '@/lib/context/auth-context';
import { Link, usePathname, useRouter } from '@/i18n/navigation';
import LanguageSwitcher from '@/components/LanguageSwitcher';
import NotificationDropdown from '@/components/NotificationDropdown';
import Image from 'next/image';
import {
Users,
Building2,
Package,
FileText,
BarChart3,
Newspaper,
ScrollText,
ArrowLeft,
LogOut,
ShieldCheck,
type LucideIcon,
} from 'lucide-react';
interface AdminNavItem {
key: string;
href: string;
icon: LucideIcon;
}
const adminNavItems: AdminNavItem[] = [
{ key: 'users', href: '/admin/users', icon: Users },
{ key: 'organizations', href: '/admin/organizations', icon: Building2 },
{ key: 'bookings', href: '/admin/bookings', icon: Package },
{ key: 'documents', href: '/admin/documents', icon: FileText },
{ key: 'csvRates', href: '/admin/csv-rates', icon: BarChart3 },
{ key: 'blog', href: '/admin/blog', icon: Newspaper },
{ key: 'logs', href: '/admin/logs', icon: ScrollText },
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const { user, logout, loading, isAuthenticated } = useAuth();
const pathname = usePathname();
const router = useRouter();
const tItems = useTranslations('components.adminPanelDropdown');
const tAdmin = useTranslations('admin');
const [sidebarOpen, setSidebarOpen] = useState(false);
// The /admin/login page lives under this segment but must render without the
// admin chrome and without triggering the role guard.
const isLoginRoute = pathname === '/admin/login';
useEffect(() => {
if (isLoginRoute || loading) return;
if (!isAuthenticated) {
router.replace('/admin/login');
return;
}
if (user && user.role !== 'ADMIN') {
router.replace('/dashboard');
}
}, [isLoginRoute, loading, isAuthenticated, user, router]);
if (isLoginRoute) {
return <>{children}</>;
}
const authorized = isAuthenticated && user?.role === 'ADMIN';
if (loading || !authorized) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-8 h-8 border-4 border-brand-turquoise border-t-transparent rounded-full animate-spin" />
</div>
);
}
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/');
return (
<div className="min-h-screen bg-gray-50">
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<div
className={`fixed inset-y-0 left-0 z-50 w-64 bg-brand-navy text-white shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between h-16 px-6 border-b border-white/10">
<Link href="/admin" className="flex items-center gap-2">
<Image
src="/assets/logos/logo-white.svg"
alt="Xpeditis"
width={44}
height={52}
priority
className="h-auto"
/>
</Link>
<button
className="lg:hidden text-white/70 hover:text-white"
onClick={() => setSidebarOpen(false)}
aria-label="Close menu"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="flex items-center gap-2 px-6 py-4 text-xs font-semibold uppercase tracking-wider text-brand-turquoise">
<ShieldCheck className="w-4 h-4" />
{tAdmin('panelTitle')}
</div>
<nav className="flex-1 px-4 pb-6 space-y-1 overflow-y-auto">
{adminNavItems.map(item => {
const Icon = item.icon;
return (
<Link
key={item.key}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={`flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors ${
isActive(item.href)
? 'bg-brand-turquoise text-brand-navy'
: 'text-white/80 hover:bg-white/10 hover:text-white'
}`}
>
<Icon className="mr-3 h-5 w-5" />
<span className="flex-1">{tItems(`items.${item.key}` as any)}</span>
</Link>
);
})}
</nav>
<div className="border-t border-white/10 p-4 space-y-3">
<Link
href="/dashboard"
className="flex items-center px-4 py-2.5 text-sm font-medium text-white/80 rounded-lg hover:bg-white/10 hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{tAdmin('backToApp')}
</Link>
<div className="flex items-center space-x-3 px-2">
<div className="w-10 h-10 bg-brand-turquoise rounded-full flex items-center justify-center text-brand-navy font-semibold">
{user?.firstName?.[0]}
{user?.lastName?.[0]}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">
{user?.firstName} {user?.lastName}
</p>
<p className="text-xs text-white/60 truncate">{user?.email}</p>
</div>
</div>
<button
onClick={logout}
className="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-red-200 bg-red-500/20 rounded-lg hover:bg-red-500/30 transition-colors"
>
<LogOut className="w-4 h-4 mr-2" />
{tAdmin('logout')}
</button>
</div>
</div>
</div>
<div className="lg:pl-64">
<div className="sticky top-0 z-10 flex items-center justify-between h-14 lg:h-16 px-4 lg:px-6 bg-white border-b">
<button
className="lg:hidden text-gray-500 hover:text-gray-700 p-1"
onClick={() => setSidebarOpen(true)}
aria-label="Open menu"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<h1 className="text-base lg:text-xl font-semibold text-gray-900 ml-3 lg:ml-0">
{adminNavItems.find(item => isActive(item.href))
? tItems(`items.${adminNavItems.find(item => isActive(item.href))!.key}` as any)
: tAdmin('title')}
</h1>
<div className="flex items-center space-x-3 lg:space-x-4">
<LanguageSwitcher variant="light" />
<NotificationDropdown />
</div>
</div>
<main className="p-4 lg:p-6">{children}</main>
</div>
</div>
);
}

View File

@ -0,0 +1,141 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Link, useRouter } from '@/i18n/navigation';
import Image from 'next/image';
import { ShieldCheck, Eye, EyeOff } from 'lucide-react';
import { login as apiLogin, getCurrentUser, logout as apiLogout } from '@/lib/api/auth';
import { useAuth } from '@/lib/context/auth-context';
export default function AdminLoginPage() {
const t = useTranslations('admin.login');
const tCommon = useTranslations('common');
const router = useRouter();
const { refreshUser } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await apiLogin({ email, password, rememberMe: false });
const me = await getCurrentUser();
if (me.role !== 'ADMIN') {
// Valid credentials but not an administrator — drop the session.
await apiLogout();
setError(t('notAdmin'));
return;
}
await refreshUser();
router.replace('/admin');
} catch (err) {
setError(t('error'));
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-brand-navy px-4">
<div className="absolute inset-0 bg-gradient-to-br from-brand-navy to-neutral-900 opacity-95" />
<div className="relative w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl p-8 sm:p-10">
<div className="flex flex-col items-center mb-8">
<Image
src="/assets/logos/logo-black.svg"
alt="Xpeditis"
width={56}
height={64}
priority
className="h-auto mb-4"
/>
<div className="flex items-center gap-2 text-brand-turquoise">
<ShieldCheck className="w-5 h-5" />
<span className="text-xs font-semibold uppercase tracking-wider">{t('badge')}</span>
</div>
<h1 className="text-h2 text-brand-navy mt-3 text-center">{t('title')}</h1>
<p className="text-body-sm text-neutral-600 mt-1 text-center">{t('subtitle')}</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-body-sm text-red-800">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="email" className="label">
{t('emailLabel')}
</label>
<input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="input w-full"
placeholder={t('emailPlaceholder')}
autoComplete="email"
disabled={isLoading}
required
/>
</div>
<div>
<label htmlFor="password" className="label">
{t('passwordLabel')}
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={e => setPassword(e.target.value)}
className="input w-full pr-12"
placeholder={t('passwordPlaceholder')}
autoComplete="current-password"
disabled={isLoading}
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-700"
tabIndex={-1}
aria-label={showPassword ? tCommon('hidePassword') : tCommon('showPassword')}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="btn-primary w-full text-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? t('submitting') : t('submit')}
</button>
</form>
<div className="mt-6 text-center">
<Link href="/login" className="text-body-sm link">
{t('backToApp')}
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,8 @@
import { redirect } from 'next/navigation';
type Params = { locale: string };
export default async function AdminIndexPage({ params }: { params: Promise<Params> }) {
const { locale } = await params;
redirect(`/${locale}/admin/users`);
}

View File

@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Eye, EyeOff } from 'lucide-react';
import { getAllUsers, updateAdminUser, deleteAdminUser } from '@/lib/api/admin'; import { getAllUsers, updateAdminUser, deleteAdminUser } from '@/lib/api/admin';
import { createUser } from '@/lib/api/users'; import { createUser } from '@/lib/api/users';
import { getAllOrganizations } from '@/lib/api/admin'; import { getAllOrganizations } from '@/lib/api/admin';
@ -27,8 +28,10 @@ interface Organization {
export default function AdminUsersPage() { export default function AdminUsersPage() {
const t = useTranslations('dashboard.admin.users'); const t = useTranslations('dashboard.admin.users');
const tCommon = useTranslations('common');
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [showPassword, setShowPassword] = useState(false);
const [organizations, setOrganizations] = useState<Organization[]>([]); const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -349,12 +352,23 @@ export default function AdminUsersPage() {
<label className="block text-sm font-medium text-gray-700"> <label className="block text-sm font-medium text-gray-700">
{t('modal.password')} {t('modal.password')}
</label> </label>
<div className="relative mt-1">
<input <input
type="password" type={showPassword ? 'text' : 'password'}
value={formData.password} value={formData.password}
onChange={e => setFormData({ ...formData, password: e.target.value })} onChange={e => setFormData({ ...formData, password: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/> />
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
tabIndex={-1}
aria-label={showPassword ? tCommon('hidePassword') : tCommon('showPassword')}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div> </div>
<div className="flex justify-end space-x-2 pt-4"> <div className="flex justify-end space-x-2 pt-4">
<button <button

View File

@ -6,6 +6,7 @@ import { ArrowLeft, Calendar, User, Tag, Clock, Share2, BookOpen, Anchor } from
import { Link } from '@/i18n/navigation'; import { Link } from '@/i18n/navigation';
import { LandingHeader, LandingFooter } from '@/components/layout'; import { LandingHeader, LandingFooter } from '@/components/layout';
import { getBlogPost, type BlogPost } from '@/lib/api/blog'; import { getBlogPost, type BlogPost } from '@/lib/api/blog';
import DOMPurify from 'isomorphic-dompurify';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
@ -107,9 +108,9 @@ export default function BlogPostContent({ slug }: { slug: string }) {
if (notFound || !post) return <NotFoundView />; if (notFound || !post) return <NotFoundView />;
const readingTime = estimateReadingTime(post.content); const readingTime = estimateReadingTime(post.content);
const processedContent = post.content.replace( // Sanitize the admin-authored HTML before rendering (stored-XSS defense in depth)
/src="(\/api\/v1\/blog\/images\/[^"]+)"/g, const processedContent = DOMPurify.sanitize(
`src="${API_BASE_URL}$1"` post.content.replace(/src="(\/api\/v1\/blog\/images\/[^"]+)"/g, `src="${API_BASE_URL}$1"`)
); );
const handleShare = () => { const handleShare = () => {

View File

@ -0,0 +1,11 @@
import { redirect } from 'next/navigation';
// Legacy redirect: the admin area moved from /dashboard/admin/* to /admin/*.
// Keeps old links and bookmarks working.
type Params = { locale: string; rest?: string[] };
export default async function LegacyAdminRedirect({ params }: { params: Promise<Params> }) {
const { locale, rest } = await params;
const sub = rest && rest.length ? `/${rest.join('/')}` : '';
redirect(`/${locale}/admin${sub}`);
}

View File

@ -3,7 +3,6 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslations, useLocale } from 'next-intl'; import { useTranslations, useLocale } from 'next-intl';
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings'; import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
import { getAuthToken } from '@/lib/api/client';
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react'; import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import ExportButton from '@/components/ExportButton'; import ExportButton from '@/components/ExportButton';
@ -278,10 +277,9 @@ export default function UserDocumentsPage() {
const formData = new FormData(); const formData = new FormData();
addFiles.forEach(file => formData.append('documents', file)); addFiles.forEach(file => formData.append('documents', file));
const token = getAuthToken();
const response = await fetch( const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${selectedBookingId}/documents`, `${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${selectedBookingId}/documents`,
{ method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData } { method: 'POST', credentials: 'include', body: formData }
); );
if (!response.ok) { if (!response.ok) {
@ -336,10 +334,9 @@ export default function UserDocumentsPage() {
const formData = new FormData(); const formData = new FormData();
formData.append('document', replaceFile); formData.append('document', replaceFile);
const token = getAuthToken();
const response = await fetch( const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToReplace.bookingId}/documents/${documentToReplace.id}`, `${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToReplace.bookingId}/documents/${documentToReplace.id}`,
{ method: 'PATCH', headers: { Authorization: `Bearer ${token}` }, body: formData } { method: 'PATCH', credentials: 'include', body: formData }
); );
if (!response.ok) { if (!response.ok) {

View File

@ -6,7 +6,6 @@ import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import NotificationDropdown from '@/components/NotificationDropdown'; import NotificationDropdown from '@/components/NotificationDropdown';
import LanguageSwitcher from '@/components/LanguageSwitcher'; import LanguageSwitcher from '@/components/LanguageSwitcher';
import AdminPanelDropdown from '@/components/admin/AdminPanelDropdown';
import Image from 'next/image'; import Image from 'next/image';
import { import {
BarChart3, BarChart3,
@ -21,6 +20,7 @@ import {
Key, Key,
Home, Home,
User, User,
ShieldCheck,
} from 'lucide-react'; } from 'lucide-react';
import { useSubscription } from '@/lib/context/subscription-context'; import { useSubscription } from '@/lib/context/subscription-context';
import StatusBadge from '@/components/ui/StatusBadge'; import StatusBadge from '@/components/ui/StatusBadge';
@ -159,7 +159,13 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
{user?.role === 'ADMIN' && ( {user?.role === 'ADMIN' && (
<div className="pt-4 mt-4 border-t"> <div className="pt-4 mt-4 border-t">
<AdminPanelDropdown /> <Link
href="/admin"
className="flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors bg-brand-navy text-white hover:bg-brand-navy/90"
>
<ShieldCheck className="mr-3 h-5 w-5" />
<span className="flex-1">{t('nav.admin')}</span>
</Link>
</div> </div>
)} )}
</nav> </nav>

View File

@ -7,15 +7,20 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Eye, EyeOff } from 'lucide-react';
import { updateUser, changePassword } from '@/lib/api'; import { updateUser, changePassword } from '@/lib/api';
export default function ProfilePage() { export default function ProfilePage() {
const t = useTranslations('dashboard.profile'); const t = useTranslations('dashboard.profile');
const tCommon = useTranslations('common');
const { user, refreshUser, loading } = useAuth(); const { user, refreshUser, loading } = useAuth();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<'profile' | 'password'>('profile'); const [activeTab, setActiveTab] = useState<'profile' | 'password'>('profile');
const [successMessage, setSuccessMessage] = useState(''); const [successMessage, setSuccessMessage] = useState('');
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const passwordSchema = z const passwordSchema = z
.object({ .object({
@ -326,13 +331,30 @@ export default function ProfilePage() {
> >
{t('passwordForm.current')} {t('passwordForm.current')}
</label> </label>
<div className="relative">
<input <input
{...passwordForm.register('currentPassword')} {...passwordForm.register('currentPassword')}
type="password" type={showCurrentPassword ? 'text' : 'password'}
id="currentPassword" id="currentPassword"
autoComplete="current-password" autoComplete="current-password"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="w-full px-4 py-2 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/> />
<button
type="button"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
tabIndex={-1}
aria-label={
showCurrentPassword ? tCommon('hidePassword') : tCommon('showPassword')
}
>
{showCurrentPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
{passwordForm.formState.errors.currentPassword && ( {passwordForm.formState.errors.currentPassword && (
<p className="mt-1 text-sm text-red-600"> <p className="mt-1 text-sm text-red-600">
{passwordForm.formState.errors.currentPassword.message} {passwordForm.formState.errors.currentPassword.message}
@ -347,13 +369,24 @@ export default function ProfilePage() {
> >
{t('passwordForm.new')} {t('passwordForm.new')}
</label> </label>
<div className="relative">
<input <input
{...passwordForm.register('newPassword')} {...passwordForm.register('newPassword')}
type="password" type={showNewPassword ? 'text' : 'password'}
id="newPassword" id="newPassword"
autoComplete="new-password" autoComplete="new-password"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="w-full px-4 py-2 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/> />
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
tabIndex={-1}
aria-label={showNewPassword ? tCommon('hidePassword') : tCommon('showPassword')}
>
{showNewPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
{passwordForm.formState.errors.newPassword && ( {passwordForm.formState.errors.newPassword && (
<p className="mt-1 text-sm text-red-600"> <p className="mt-1 text-sm text-red-600">
{passwordForm.formState.errors.newPassword.message} {passwordForm.formState.errors.newPassword.message}
@ -369,13 +402,30 @@ export default function ProfilePage() {
> >
{t('passwordForm.confirm')} {t('passwordForm.confirm')}
</label> </label>
<div className="relative">
<input <input
{...passwordForm.register('confirmPassword')} {...passwordForm.register('confirmPassword')}
type="password" type={showConfirmPassword ? 'text' : 'password'}
id="confirmPassword" id="confirmPassword"
autoComplete="new-password" autoComplete="new-password"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="w-full px-4 py-2 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/> />
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
tabIndex={-1}
aria-label={
showConfirmPassword ? tCommon('hidePassword') : tCommon('showPassword')
}
>
{showConfirmPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
{passwordForm.formState.errors.confirmPassword && ( {passwordForm.formState.errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600"> <p className="mt-1 text-sm text-red-600">
{passwordForm.formState.errors.confirmPassword.message} {passwordForm.formState.errors.confirmPassword.message}

View File

@ -68,7 +68,14 @@ export default function ForgotPasswordPage() {
<p <p
className="text-body text-neutral-600" className="text-body text-neutral-600"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: t('successMessage', { email }), __html: t('successMessage', {
// Escape the user-typed email before HTML interpolation
email: email
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;'),
}),
}} }}
/> />
<p className="text-body-sm text-neutral-500 mt-3">{t('successHint')}</p> <p className="text-body-sm text-neutral-500 mt-3">{t('successHint')}</p>

View File

@ -5,6 +5,7 @@ import { Link } from '@/i18n/navigation';
import Image from 'next/image'; import Image from 'next/image';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Eye, EyeOff } from 'lucide-react';
import { useAuth } from '@/lib/context/auth-context'; import { useAuth } from '@/lib/context/auth-context';
function AnimatedCounter({ function AnimatedCounter({
@ -97,9 +98,11 @@ function LoginPageContent() {
const tLogin = useTranslations('auth.login'); const tLogin = useTranslations('auth.login');
const tPanel = useTranslations('auth.sidePanel'); const tPanel = useTranslations('auth.sidePanel');
const tFooter = useTranslations('auth.footerLinks'); const tFooter = useTranslations('auth.footerLinks');
const tCommon = useTranslations('common');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(false); const [rememberMe, setRememberMe] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({}); const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
@ -262,12 +265,13 @@ function LoginPageContent() {
> >
{tLogin('passwordLabel')} {tLogin('passwordLabel')}
</label> </label>
<div className="relative">
<input <input
id="password" id="password"
type="password" type={showPassword ? 'text' : 'password'}
value={password} value={password}
onChange={handlePasswordChange} onChange={handlePasswordChange}
className={`input w-full ${ className={`input w-full pr-12 ${
fieldErrors.password fieldErrors.password
? 'border-red-500 focus:border-red-500 focus:ring-red-500 bg-red-50' ? 'border-red-500 focus:border-red-500 focus:ring-red-500 bg-red-50'
: '' : ''
@ -278,6 +282,16 @@ function LoginPageContent() {
aria-invalid={!!fieldErrors.password} aria-invalid={!!fieldErrors.password}
aria-describedby={fieldErrors.password ? 'password-error' : undefined} aria-describedby={fieldErrors.password ? 'password-error' : undefined}
/> />
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-700"
tabIndex={-1}
aria-label={showPassword ? tCommon('hidePassword') : tCommon('showPassword')}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
{fieldErrors.password && ( {fieldErrors.password && (
<p <p
id="password-error" id="password-error"

View File

@ -5,6 +5,7 @@ import { useSearchParams } from 'next/navigation';
import { Link, useRouter } from '@/i18n/navigation'; import { Link, useRouter } from '@/i18n/navigation';
import Image from 'next/image'; import Image from 'next/image';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Eye, EyeOff } from 'lucide-react';
import { register } from '@/lib/api'; import { register } from '@/lib/api';
import { verifyInvitation, type InvitationResponse } from '@/lib/api/invitations'; import { verifyInvitation, type InvitationResponse } from '@/lib/api/invitations';
import type { OrganizationType } from '@/types/api'; import type { OrganizationType } from '@/types/api';
@ -57,6 +58,7 @@ function RegisterPageContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const t = useTranslations('auth.register'); const t = useTranslations('auth.register');
const tFooter = useTranslations('auth.footerLinks'); const tFooter = useTranslations('auth.footerLinks');
const tCommon = useTranslations('common');
const [step, setStep] = useState<1 | 2>(1); const [step, setStep] = useState<1 | 2>(1);
const [statsActive, setStatsActive] = useState(false); const [statsActive, setStatsActive] = useState(false);
@ -81,6 +83,8 @@ function RegisterPageContent() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [organizationName, setOrganizationName] = useState(''); const [organizationName, setOrganizationName] = useState('');
const [organizationType, setOrganizationType] = useState<OrganizationType>('FREIGHT_FORWARDER'); const [organizationType, setOrganizationType] = useState<OrganizationType>('FREIGHT_FORWARDER');
@ -513,17 +517,28 @@ function RegisterPageContent() {
<label htmlFor="password" className="label"> <label htmlFor="password" className="label">
{t('passwordLabel')} {t('passwordLabel')}
</label> </label>
<div className="relative">
<input <input
id="password" id="password"
type="password" type={showPassword ? 'text' : 'password'}
required required
value={password} value={password}
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
className="input w-full" className="input w-full pr-12"
placeholder={t('passwordPlaceholder')} placeholder={t('passwordPlaceholder')}
autoComplete="new-password" autoComplete="new-password"
disabled={isLoading} disabled={isLoading}
/> />
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-700"
tabIndex={-1}
aria-label={showPassword ? tCommon('hidePassword') : tCommon('showPassword')}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
<p className="mt-1.5 text-body-xs text-neutral-500">{t('passwordHint')}</p> <p className="mt-1.5 text-body-xs text-neutral-500">{t('passwordHint')}</p>
</div> </div>
@ -531,17 +546,34 @@ function RegisterPageContent() {
<label htmlFor="confirmPassword" className="label"> <label htmlFor="confirmPassword" className="label">
{t('confirmPasswordLabel')} {t('confirmPasswordLabel')}
</label> </label>
<div className="relative">
<input <input
id="confirmPassword" id="confirmPassword"
type="password" type={showConfirmPassword ? 'text' : 'password'}
required required
value={confirmPassword} value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)} onChange={e => setConfirmPassword(e.target.value)}
className="input w-full" className="input w-full pr-12"
placeholder={t('passwordPlaceholder')} placeholder={t('passwordPlaceholder')}
autoComplete="new-password" autoComplete="new-password"
disabled={isLoading} disabled={isLoading}
/> />
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-700"
tabIndex={-1}
aria-label={
showConfirmPassword ? tCommon('hidePassword') : tCommon('showPassword')
}
>
{showConfirmPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
</div> </div>
<button <button

View File

@ -5,16 +5,20 @@ import { useSearchParams } from 'next/navigation';
import { Link, useRouter } from '@/i18n/navigation'; import { Link, useRouter } from '@/i18n/navigation';
import Image from 'next/image'; import Image from 'next/image';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Eye, EyeOff } from 'lucide-react';
import { resetPassword } from '@/lib/api/auth'; import { resetPassword } from '@/lib/api/auth';
function ResetPasswordContent() { function ResetPasswordContent() {
const t = useTranslations('auth.resetPassword'); const t = useTranslations('auth.resetPassword');
const tFooter = useTranslations('auth.footerLinks'); const tFooter = useTranslations('auth.footerLinks');
const tCommon = useTranslations('common');
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const [token, setToken] = useState(''); const [token, setToken] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -159,17 +163,28 @@ function ResetPasswordContent() {
<label htmlFor="password" className="label"> <label htmlFor="password" className="label">
{t('passwordLabel')} {t('passwordLabel')}
</label> </label>
<div className="relative">
<input <input
id="password" id="password"
type="password" type={showPassword ? 'text' : 'password'}
required required
value={password} value={password}
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
className="input w-full" className="input w-full pr-12"
placeholder="••••••••••••" placeholder="••••••••••••"
autoComplete="new-password" autoComplete="new-password"
disabled={loading} disabled={loading}
/> />
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-700"
tabIndex={-1}
aria-label={showPassword ? tCommon('hidePassword') : tCommon('showPassword')}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
<p className="mt-1.5 text-body-xs text-neutral-500">{t('passwordHint')}</p> <p className="mt-1.5 text-body-xs text-neutral-500">{t('passwordHint')}</p>
</div> </div>
@ -177,17 +192,34 @@ function ResetPasswordContent() {
<label htmlFor="confirmPassword" className="label"> <label htmlFor="confirmPassword" className="label">
{t('confirmPasswordLabel')} {t('confirmPasswordLabel')}
</label> </label>
<div className="relative">
<input <input
id="confirmPassword" id="confirmPassword"
type="password" type={showConfirmPassword ? 'text' : 'password'}
required required
value={confirmPassword} value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)} onChange={e => setConfirmPassword(e.target.value)}
className="input w-full" className="input w-full pr-12"
placeholder="••••••••••••" placeholder="••••••••••••"
autoComplete="new-password" autoComplete="new-password"
disabled={loading} disabled={loading}
/> />
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-700"
tabIndex={-1}
aria-label={
showConfirmPassword ? tCommon('hidePassword') : tCommon('showPassword')
}
>
{showConfirmPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
</div> </div>
<button <button

View File

@ -1,11 +1,11 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, Suspense } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { Link, useRouter } from '@/i18n/navigation'; import { Link, useRouter } from '@/i18n/navigation';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
export default function VerifyEmailPage() { function VerifyEmailContent() {
const t = useTranslations('auth.verifyEmail'); const t = useTranslations('auth.verifyEmail');
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
@ -140,3 +140,12 @@ export default function VerifyEmailPage() {
</div> </div>
); );
} }
export default function VerifyEmailPage() {
// useSearchParams() requires a Suspense boundary for prerendering (Next 14.2+)
return (
<Suspense fallback={null}>
<VerifyEmailContent />
</Suspense>
);
}

View File

@ -0,0 +1,6 @@
/**
* Health check endpoint used by the Docker HEALTHCHECK.
*/
export function GET() {
return Response.json({ status: 'ok', timestamp: new Date().toISOString() });
}

View File

@ -25,7 +25,29 @@
"discover": "Discover", "discover": "Discover",
"tryNow": "Try now", "tryNow": "Try now",
"continue": "Continue", "continue": "Continue",
"submit": "Submit" "submit": "Submit",
"showPassword": "Show password",
"hidePassword": "Hide password"
},
"admin": {
"title": "Administration",
"panelTitle": "Administration",
"backToApp": "Back to app",
"logout": "Log out",
"login": {
"badge": "Restricted access",
"title": "Admin sign in",
"subtitle": "Access the Xpeditis administration area",
"emailLabel": "Email",
"emailPlaceholder": "you@company.com",
"passwordLabel": "Password",
"passwordPlaceholder": "********",
"submit": "Sign in",
"submitting": "Signing in...",
"notAdmin": "Access denied: this account is not an administrator.",
"error": "Sign in failed. Please check your credentials.",
"backToApp": "Back to site"
}
}, },
"language": { "language": {
"label": "Language", "label": "Language",
@ -303,7 +325,8 @@
"wiki": "Maritime Wiki", "wiki": "Maritime Wiki",
"organization": "Organization", "organization": "Organization",
"apiKeys": "API Keys", "apiKeys": "API Keys",
"users": "Users" "users": "Users",
"admin": "Administration"
}, },
"topbar": { "topbar": {
"defaultTitle": "Dashboard" "defaultTitle": "Dashboard"
@ -1723,7 +1746,10 @@
], ],
"extensionsTitle": "Coverage Extensions", "extensionsTitle": "Coverage Extensions",
"extensions": [ "extensions": [
{ "name": "War clause", "description": "Covers losses due to war, terrorism, piracy" }, {
"name": "War clause",
"description": "Covers losses due to war, terrorism, piracy"
},
{ {
"name": "Strikes clause", "name": "Strikes clause",
"description": "Covers losses due to strikes, riots, civil commotion" "description": "Covers losses due to strikes, riots, civil commotion"
@ -1849,15 +1875,42 @@
"colItem": "Item", "colItem": "Item",
"colAmount": "Amount", "colAmount": "Amount",
"exampleItems": [ "exampleItems": [
{ "item": "Base ocean freight", "amount": "1,200 USD" }, {
{ "item": "BAF (Bunker)", "amount": "350 USD" }, "item": "Base ocean freight",
{ "item": "CAF (Currency)", "amount": "50 USD" }, "amount": "1,200 USD"
{ "item": "THC Origin", "amount": "180 USD" }, },
{ "item": "THC Destination", "amount": "220 USD" }, {
{ "item": "B/L Fee", "amount": "55 USD" }, "item": "BAF (Bunker)",
{ "item": "ISPS", "amount": "30 USD" }, "amount": "350 USD"
{ "item": "Pre-carriage", "amount": "250 USD" }, },
{ "item": "Total", "amount": "2,335 USD" } {
"item": "CAF (Currency)",
"amount": "50 USD"
},
{
"item": "THC Origin",
"amount": "180 USD"
},
{
"item": "THC Destination",
"amount": "220 USD"
},
{
"item": "B/L Fee",
"amount": "55 USD"
},
{
"item": "ISPS",
"amount": "30 USD"
},
{
"item": "Pre-carriage",
"amount": "250 USD"
},
{
"item": "Total",
"amount": "2,335 USD"
}
] ]
}, },
"conteneurs": { "conteneurs": {
@ -1929,7 +1982,10 @@
], ],
"specialEquipmentTitle": "Special Equipment", "specialEquipmentTitle": "Special Equipment",
"specialEquipment": [ "specialEquipment": [
{ "name": "ISO Tank", "description": "For liquids, chemicals, food products in bulk" }, {
"name": "ISO Tank",
"description": "For liquids, chemicals, food products in bulk"
},
{ {
"name": "Bulk Container", "name": "Bulk Container",
"description": "For dry bulk (grains, minerals) — top hatch" "description": "For dry bulk (grains, minerals) — top hatch"
@ -1951,11 +2007,26 @@
"condition": "Standard general cargo", "condition": "Standard general cargo",
"recommendation": "20' or 40' Dry depending on volume" "recommendation": "20' or 40' Dry depending on volume"
}, },
{ "condition": "Temperature-sensitive goods", "recommendation": "Reefer 20' or 40'" }, {
{ "condition": "Over-height cargo (> 2.2m)", "recommendation": "Open Top or Flat Rack" }, "condition": "Temperature-sensitive goods",
{ "condition": "Over-length/weight cargo", "recommendation": "Flat Rack or Platform" }, "recommendation": "Reefer 20' or 40'"
{ "condition": "Bulk liquids", "recommendation": "ISO Tank" }, },
{ "condition": "Volume < 15 m³", "recommendation": "Consider LCL" } {
"condition": "Over-height cargo (> 2.2m)",
"recommendation": "Open Top or Flat Rack"
},
{
"condition": "Over-length/weight cargo",
"recommendation": "Flat Rack or Platform"
},
{
"condition": "Bulk liquids",
"recommendation": "ISO Tank"
},
{
"condition": "Volume < 15 m³",
"recommendation": "Consider LCL"
}
] ]
}, },
"documentsTransport": { "documentsTransport": {
@ -2143,7 +2214,10 @@
"type": "VAT", "type": "VAT",
"description": "Applied on (customs value + import duties + transport). 20% standard rate." "description": "Applied on (customs value + import duties + transport). 20% standard rate."
}, },
{ "type": "Excise Duties", "description": "Specific to alcohol, tobacco, hydrocarbons." } {
"type": "Excise Duties",
"description": "Specific to alcohol, tobacco, hydrocarbons."
}
] ]
}, },
"imdg": { "imdg": {
@ -2202,8 +2276,16 @@
"description": "Toxic and infectious substances", "description": "Toxic and infectious substances",
"subdivisions": ["6.1 Toxic substances", "6.2 Infectious substances"] "subdivisions": ["6.1 Toxic substances", "6.2 Infectious substances"]
}, },
{ "class": "Class 7", "name": "Radioactive", "description": "Radioactive materials" }, {
{ "class": "Class 8", "name": "Corrosive", "description": "Corrosive substances" }, "class": "Class 7",
"name": "Radioactive",
"description": "Radioactive materials"
},
{
"class": "Class 8",
"name": "Corrosive",
"description": "Corrosive substances"
},
{ {
"class": "Class 9", "class": "Class 9",
"name": "Miscellaneous", "name": "Miscellaneous",
@ -2239,8 +2321,14 @@
"group": "Group I (X)", "group": "Group I (X)",
"description": "High danger — most stringent packaging requirements" "description": "High danger — most stringent packaging requirements"
}, },
{ "group": "Group II (Y)", "description": "Medium danger — standard packaging" }, {
{ "group": "Group III (Z)", "description": "Low danger — less stringent requirements" } "group": "Group II (Y)",
"description": "Medium danger — standard packaging"
},
{
"group": "Group III (Z)",
"description": "Low danger — less stringent requirements"
}
], ],
"labelingTitle": "Labeling and Placarding", "labelingTitle": "Labeling and Placarding",
"labelingContent": "Each package must display: UN number, proper shipping name, hazard labels and class. Containers must display 250mm × 250mm placards matching the IMDG class. Mixed loads require labels for each dangerous good.", "labelingContent": "Each package must display: UN number, proper shipping name, hazard labels and class. Containers must display 250mm × 250mm placards matching the IMDG class. Mixed loads require labels for each dangerous good.",
@ -2264,7 +2352,11 @@
"lcl": "< 15 m³ or < 10 tonnes", "lcl": "< 15 m³ or < 10 tonnes",
"fcl": "> 15 m³ or full container" "fcl": "> 15 m³ or full container"
}, },
{ "criterion": "Price", "lcl": "Per CBM (m³) or tonne", "fcl": "Fixed per container" }, {
"criterion": "Price",
"lcl": "Per CBM (m³) or tonne",
"fcl": "Fixed per container"
},
{ {
"criterion": "Security", "criterion": "Security",
"lcl": "Moderate (shared with others)", "lcl": "Moderate (shared with others)",
@ -2373,7 +2465,10 @@
"role": "Applicant (Importer)", "role": "Applicant (Importer)",
"description": "The buyer who requests the L/C at their bank" "description": "The buyer who requests the L/C at their bank"
}, },
{ "role": "Issuing Bank", "description": "The importer's bank that issues the L/C" }, {
"role": "Issuing Bank",
"description": "The importer's bank that issues the L/C"
},
{ {
"role": "Beneficiary (Exporter)", "role": "Beneficiary (Exporter)",
"description": "The seller who benefits from the L/C" "description": "The seller who benefits from the L/C"
@ -2397,7 +2492,10 @@
"name": "Commercial Invoice", "name": "Commercial Invoice",
"description": "In exact conformity with the L/C — amounts, currencies, description" "description": "In exact conformity with the L/C — amounts, currencies, description"
}, },
{ "name": "Packing List", "description": "Consistent with invoice and B/L" }, {
"name": "Packing List",
"description": "Consistent with invoice and B/L"
},
{ {
"name": "Insurance Certificate", "name": "Insurance Certificate",
"description": "Required if CIF or CIP — amounts and coverage per L/C" "description": "Required if CIF or CIP — amounts and coverage per L/C"
@ -2437,7 +2535,10 @@
"label": "Presentation deadline", "label": "Presentation deadline",
"description": "Number of days after shipment to present documents (typically 21 days)" "description": "Number of days after shipment to present documents (typically 21 days)"
}, },
{ "label": "L/C expiry", "description": "Final deadline for all document presentation" } {
"label": "L/C expiry",
"description": "Final deadline for all document presentation"
}
], ],
"costsTitle": "Costs", "costsTitle": "Costs",
"costsItems": [ "costsItems": [
@ -2449,8 +2550,14 @@
"label": "Confirmation commission", "label": "Confirmation commission",
"description": "0.20.5% per quarter (confirming bank)" "description": "0.20.5% per quarter (confirming bank)"
}, },
{ "label": "Amendment fee", "description": "Fixed fee per amendment" }, {
{ "label": "Discrepancy fee", "description": "Fixed fee in case of document discrepancy" } "label": "Amendment fee",
"description": "Fixed fee per amendment"
},
{
"label": "Discrepancy fee",
"description": "Fixed fee in case of document discrepancy"
}
] ]
}, },
"portsRoutes": { "portsRoutes": {
@ -2529,16 +2636,66 @@
"colCountry": "Country", "colCountry": "Country",
"colTeu": "TEU / year", "colTeu": "TEU / year",
"ports": [ "ports": [
{ "rank": 1, "port": "Shanghai", "country": "China", "teu": "47M" }, {
{ "rank": 2, "port": "Singapore", "country": "Singapore", "teu": "37M" }, "rank": 1,
{ "rank": 3, "port": "Ningbo-Zhoushan", "country": "China", "teu": "33M" }, "port": "Shanghai",
{ "rank": 4, "port": "Shenzhen", "country": "China", "teu": "29M" }, "country": "China",
{ "rank": 5, "port": "Guangzhou", "country": "China", "teu": "24M" }, "teu": "47M"
{ "rank": 6, "port": "Qingdao", "country": "China", "teu": "24M" }, },
{ "rank": 7, "port": "Busan", "country": "South Korea", "teu": "22M" }, {
{ "rank": 8, "port": "Tianjin", "country": "China", "teu": "21M" }, "rank": 2,
{ "rank": 9, "port": "Dubai (Jebel Ali)", "country": "UAE", "teu": "15M" }, "port": "Singapore",
{ "rank": 10, "port": "Rotterdam", "country": "Netherlands", "teu": "15M" } "country": "Singapore",
"teu": "37M"
},
{
"rank": 3,
"port": "Ningbo-Zhoushan",
"country": "China",
"teu": "33M"
},
{
"rank": 4,
"port": "Shenzhen",
"country": "China",
"teu": "29M"
},
{
"rank": 5,
"port": "Guangzhou",
"country": "China",
"teu": "24M"
},
{
"rank": 6,
"port": "Qingdao",
"country": "China",
"teu": "24M"
},
{
"rank": 7,
"port": "Busan",
"country": "South Korea",
"teu": "22M"
},
{
"rank": 8,
"port": "Tianjin",
"country": "China",
"teu": "21M"
},
{
"rank": 9,
"port": "Dubai (Jebel Ali)",
"country": "UAE",
"teu": "15M"
},
{
"rank": 10,
"port": "Rotterdam",
"country": "Netherlands",
"teu": "15M"
}
], ],
"hubGatewayTitle": "Hub vs Gateway", "hubGatewayTitle": "Hub vs Gateway",
"hubTitle": "Hub Port", "hubTitle": "Hub Port",
@ -2576,7 +2733,11 @@
"description": "Empty weight of the container (shown on the door)", "description": "Empty weight of the container (shown on the door)",
"example": "2,200 kg (20')" "example": "2,200 kg (20')"
}, },
{ "element": "Cargo", "description": "Gross weight of all goods", "example": "Variable" }, {
"element": "Cargo",
"description": "Gross weight of all goods",
"example": "Variable"
},
{ {
"element": "Packaging", "element": "Packaging",
"description": "Pallets, cartons, plastic film...", "description": "Pallets, cartons, plastic film...",
@ -2642,10 +2803,22 @@
"consequenceValue": "Re-weighing at shipper's expense, possible delay", "consequenceValue": "Re-weighing at shipper's expense, possible delay",
"sanctionsTitle": "Sanctions by Region", "sanctionsTitle": "Sanctions by Region",
"sanctions": [ "sanctions": [
{ "region": "France", "sanction": "Fine up to €7,500 and refusal to load" }, {
{ "region": "USA", "sanction": "Refusal to load, fine from coast guard" }, "region": "France",
{ "region": "China", "sanction": "Refusal to load, port penalties" }, "sanction": "Fine up to €7,500 and refusal to load"
{ "region": "European Union", "sanction": "Variable application by member state" } },
{
"region": "USA",
"sanction": "Refusal to load, fine from coast guard"
},
{
"region": "China",
"sanction": "Refusal to load, port penalties"
},
{
"region": "European Union",
"sanction": "Variable application by member state"
}
], ],
"tipsTitle": "Best Practices", "tipsTitle": "Best Practices",
"tips": [ "tips": [
@ -2740,13 +2913,41 @@
"colTime": "Transit Time", "colTime": "Transit Time",
"colVia": "Via", "colVia": "Via",
"transitTimes": [ "transitTimes": [
{ "route": "Shanghai → Rotterdam", "time": "2832 days", "via": "Suez" }, {
{ "route": "Shanghai → Le Havre", "time": "3035 days", "via": "Suez" }, "route": "Shanghai → Rotterdam",
{ "route": "Shanghai → Los Angeles", "time": "1215 days", "via": "Direct Pacific" }, "time": "2832 days",
{ "route": "Shanghai → New York", "time": "3540 days", "via": "Suez or Panama" }, "via": "Suez"
{ "route": "Rotterdam → New York", "time": "1014 days", "via": "Direct Atlantic" }, },
{ "route": "Mumbai → Rotterdam", "time": "1822 days", "via": "Suez" }, {
{ "route": "Santos → Rotterdam", "time": "1822 days", "via": "Direct Atlantic" } "route": "Shanghai → Le Havre",
"time": "3035 days",
"via": "Suez"
},
{
"route": "Shanghai → Los Angeles",
"time": "1215 days",
"via": "Direct Pacific"
},
{
"route": "Shanghai → New York",
"time": "3540 days",
"via": "Suez or Panama"
},
{
"route": "Rotterdam → New York",
"time": "1014 days",
"via": "Direct Atlantic"
},
{
"route": "Mumbai → Rotterdam",
"time": "1822 days",
"via": "Suez"
},
{
"route": "Santos → Rotterdam",
"time": "1822 days",
"via": "Direct Atlantic"
}
], ],
"transitNote": "Note: These times are indicative and vary depending on rotations, transshipments and conditions.", "transitNote": "Note: These times are indicative and vary depending on rotations, transshipments and conditions.",
"freeTimeTitle": "Free Time (Free Days)", "freeTimeTitle": "Free Time (Free Days)",

View File

@ -25,7 +25,29 @@
"discover": "Découvrir", "discover": "Découvrir",
"tryNow": "Essayer maintenant", "tryNow": "Essayer maintenant",
"continue": "Continuer", "continue": "Continuer",
"submit": "Envoyer" "submit": "Envoyer",
"showPassword": "Afficher le mot de passe",
"hidePassword": "Masquer le mot de passe"
},
"admin": {
"title": "Espace administration",
"panelTitle": "Administration",
"backToApp": "Retour à l'application",
"logout": "Déconnexion",
"login": {
"badge": "Accès réservé",
"title": "Connexion administrateur",
"subtitle": "Accédez à l'espace d'administration Xpeditis",
"emailLabel": "Email",
"emailPlaceholder": "vous@entreprise.com",
"passwordLabel": "Mot de passe",
"passwordPlaceholder": "********",
"submit": "Se connecter",
"submitting": "Connexion...",
"notAdmin": "Accès refusé : ce compte n'est pas administrateur.",
"error": "Échec de la connexion. Vérifiez vos identifiants.",
"backToApp": "Retour au site"
}
}, },
"language": { "language": {
"label": "Langue", "label": "Langue",
@ -303,7 +325,8 @@
"wiki": "Wiki Maritime", "wiki": "Wiki Maritime",
"organization": "Organisation", "organization": "Organisation",
"apiKeys": "Clés API", "apiKeys": "Clés API",
"users": "Utilisateurs" "users": "Utilisateurs",
"admin": "Administration"
}, },
"topbar": { "topbar": {
"defaultTitle": "Tableau de bord" "defaultTitle": "Tableau de bord"
@ -1858,15 +1881,42 @@
"colItem": "Poste", "colItem": "Poste",
"colAmount": "Montant", "colAmount": "Montant",
"exampleItems": [ "exampleItems": [
{ "item": "Fret maritime de base", "amount": "1 200 USD" }, {
{ "item": "BAF (Bunker)", "amount": "350 USD" }, "item": "Fret maritime de base",
{ "item": "CAF (Devise)", "amount": "50 USD" }, "amount": "1 200 USD"
{ "item": "THC Origine", "amount": "180 USD" }, },
{ "item": "THC Destination", "amount": "220 USD" }, {
{ "item": "Frais B/L", "amount": "55 USD" }, "item": "BAF (Bunker)",
{ "item": "ISPS", "amount": "30 USD" }, "amount": "350 USD"
{ "item": "Pré-acheminement", "amount": "250 USD" }, },
{ "item": "Total", "amount": "2 335 USD" } {
"item": "CAF (Devise)",
"amount": "50 USD"
},
{
"item": "THC Origine",
"amount": "180 USD"
},
{
"item": "THC Destination",
"amount": "220 USD"
},
{
"item": "Frais B/L",
"amount": "55 USD"
},
{
"item": "ISPS",
"amount": "30 USD"
},
{
"item": "Pré-acheminement",
"amount": "250 USD"
},
{
"item": "Total",
"amount": "2 335 USD"
}
] ]
}, },
"conteneurs": { "conteneurs": {
@ -1975,8 +2025,14 @@
"condition": "Marchandises hors-gabarit / très lourdes", "condition": "Marchandises hors-gabarit / très lourdes",
"recommendation": "Flat Rack ou Plateforme" "recommendation": "Flat Rack ou Plateforme"
}, },
{ "condition": "Liquides en vrac", "recommendation": "ISO Tank" }, {
{ "condition": "Volume < 15 m³", "recommendation": "Envisager le LCL" } "condition": "Liquides en vrac",
"recommendation": "ISO Tank"
},
{
"condition": "Volume < 15 m³",
"recommendation": "Envisager le LCL"
}
] ]
}, },
"documentsTransport": { "documentsTransport": {
@ -2226,8 +2282,16 @@
"description": "Matières toxiques et infectieuses", "description": "Matières toxiques et infectieuses",
"subdivisions": ["6.1 Matières toxiques", "6.2 Matières infectieuses"] "subdivisions": ["6.1 Matières toxiques", "6.2 Matières infectieuses"]
}, },
{ "class": "Classe 7", "name": "Radioactifs", "description": "Matières radioactives" }, {
{ "class": "Classe 8", "name": "Corrosifs", "description": "Matières corrosives" }, "class": "Classe 7",
"name": "Radioactifs",
"description": "Matières radioactives"
},
{
"class": "Classe 8",
"name": "Corrosifs",
"description": "Matières corrosives"
},
{ {
"class": "Classe 9", "class": "Classe 9",
"name": "Divers", "name": "Divers",
@ -2263,8 +2327,14 @@
"group": "Groupe I (X)", "group": "Groupe I (X)",
"description": "Grand danger — exigences d'emballage les plus strictes" "description": "Grand danger — exigences d'emballage les plus strictes"
}, },
{ "group": "Groupe II (Y)", "description": "Danger moyen — emballage standard" }, {
{ "group": "Groupe III (Z)", "description": "Faible danger — exigences moins strictes" } "group": "Groupe II (Y)",
"description": "Danger moyen — emballage standard"
},
{
"group": "Groupe III (Z)",
"description": "Faible danger — exigences moins strictes"
}
], ],
"labelingTitle": "Étiquetage et Placardage", "labelingTitle": "Étiquetage et Placardage",
"labelingContent": "Chaque colis doit afficher : numéro ONU, désignation officielle de transport, étiquettes de danger et classe. Les conteneurs doivent afficher des plaques-étiquettes de 250mm × 250mm correspondant à la classe IMDG. Les chargements mixtes requièrent des étiquettes pour chaque marchandise dangereuse.", "labelingContent": "Chaque colis doit afficher : numéro ONU, désignation officielle de transport, étiquettes de danger et classe. Les conteneurs doivent afficher des plaques-étiquettes de 250mm × 250mm correspondant à la classe IMDG. Les chargements mixtes requièrent des étiquettes pour chaque marchandise dangereuse.",
@ -2428,7 +2498,10 @@
"name": "Facture Commerciale", "name": "Facture Commerciale",
"description": "En exacte conformité avec la L/C — montants, devises, description" "description": "En exacte conformité avec la L/C — montants, devises, description"
}, },
{ "name": "Liste de Colisage", "description": "Cohérente avec la facture et le B/L" }, {
"name": "Liste de Colisage",
"description": "Cohérente avec la facture et le B/L"
},
{ {
"name": "Certificat d'Assurance", "name": "Certificat d'Assurance",
"description": "Requis si CIF ou CIP — montants et couverture selon L/C" "description": "Requis si CIF ou CIP — montants et couverture selon L/C"
@ -2483,7 +2556,10 @@
"label": "Commission de confirmation", "label": "Commission de confirmation",
"description": "0,20,5% par trimestre (banque confirmatrice)" "description": "0,20,5% par trimestre (banque confirmatrice)"
}, },
{ "label": "Frais d'amendement", "description": "Frais fixes par amendement" }, {
"label": "Frais d'amendement",
"description": "Frais fixes par amendement"
},
{ {
"label": "Frais de réserve", "label": "Frais de réserve",
"description": "Frais fixes en cas de réserve sur les documents" "description": "Frais fixes en cas de réserve sur les documents"
@ -2566,16 +2642,66 @@
"colCountry": "Pays", "colCountry": "Pays",
"colTeu": "TEU / an", "colTeu": "TEU / an",
"ports": [ "ports": [
{ "rank": 1, "port": "Shanghai", "country": "Chine", "teu": "47M" }, {
{ "rank": 2, "port": "Singapour", "country": "Singapour", "teu": "37M" }, "rank": 1,
{ "rank": 3, "port": "Ningbo-Zhoushan", "country": "Chine", "teu": "33M" }, "port": "Shanghai",
{ "rank": 4, "port": "Shenzhen", "country": "Chine", "teu": "29M" }, "country": "Chine",
{ "rank": 5, "port": "Guangzhou", "country": "Chine", "teu": "24M" }, "teu": "47M"
{ "rank": 6, "port": "Qingdao", "country": "Chine", "teu": "24M" }, },
{ "rank": 7, "port": "Busan", "country": "Corée du Sud", "teu": "22M" }, {
{ "rank": 8, "port": "Tianjin", "country": "Chine", "teu": "21M" }, "rank": 2,
{ "rank": 9, "port": "Dubaï (Jebel Ali)", "country": "EAU", "teu": "15M" }, "port": "Singapour",
{ "rank": 10, "port": "Rotterdam", "country": "Pays-Bas", "teu": "15M" } "country": "Singapour",
"teu": "37M"
},
{
"rank": 3,
"port": "Ningbo-Zhoushan",
"country": "Chine",
"teu": "33M"
},
{
"rank": 4,
"port": "Shenzhen",
"country": "Chine",
"teu": "29M"
},
{
"rank": 5,
"port": "Guangzhou",
"country": "Chine",
"teu": "24M"
},
{
"rank": 6,
"port": "Qingdao",
"country": "Chine",
"teu": "24M"
},
{
"rank": 7,
"port": "Busan",
"country": "Corée du Sud",
"teu": "22M"
},
{
"rank": 8,
"port": "Tianjin",
"country": "Chine",
"teu": "21M"
},
{
"rank": 9,
"port": "Dubaï (Jebel Ali)",
"country": "EAU",
"teu": "15M"
},
{
"rank": 10,
"port": "Rotterdam",
"country": "Pays-Bas",
"teu": "15M"
}
], ],
"hubGatewayTitle": "Hub vs Gateway", "hubGatewayTitle": "Hub vs Gateway",
"hubTitle": "Port Hub", "hubTitle": "Port Hub",
@ -2683,10 +2809,22 @@
"consequenceValue": "Nouvelle pesée à la charge de l'expéditeur, retard possible", "consequenceValue": "Nouvelle pesée à la charge de l'expéditeur, retard possible",
"sanctionsTitle": "Sanctions par Région", "sanctionsTitle": "Sanctions par Région",
"sanctions": [ "sanctions": [
{ "region": "France", "sanction": "Amende jusqu'à 7 500€ et refus d'embarquement" }, {
{ "region": "USA", "sanction": "Refus d'embarquement, amende par la garde côtière" }, "region": "France",
{ "region": "Chine", "sanction": "Refus d'embarquement, pénalités portuaires" }, "sanction": "Amende jusqu'à 7 500€ et refus d'embarquement"
{ "region": "Union Européenne", "sanction": "Application variable selon pays membre" } },
{
"region": "USA",
"sanction": "Refus d'embarquement, amende par la garde côtière"
},
{
"region": "Chine",
"sanction": "Refus d'embarquement, pénalités portuaires"
},
{
"region": "Union Européenne",
"sanction": "Application variable selon pays membre"
}
], ],
"tipsTitle": "Bonnes Pratiques", "tipsTitle": "Bonnes Pratiques",
"tips": [ "tips": [
@ -2781,13 +2919,41 @@
"colTime": "Transit Time", "colTime": "Transit Time",
"colVia": "Via", "colVia": "Via",
"transitTimes": [ "transitTimes": [
{ "route": "Shanghai → Rotterdam", "time": "2832 jours", "via": "Suez" }, {
{ "route": "Shanghai → Le Havre", "time": "3035 jours", "via": "Suez" }, "route": "Shanghai → Rotterdam",
{ "route": "Shanghai → Los Angeles", "time": "1215 jours", "via": "Pacifique direct" }, "time": "2832 jours",
{ "route": "Shanghai → New York", "time": "3540 jours", "via": "Suez ou Panama" }, "via": "Suez"
{ "route": "Rotterdam → New York", "time": "1014 jours", "via": "Atlantique direct" }, },
{ "route": "Mumbai → Rotterdam", "time": "1822 jours", "via": "Suez" }, {
{ "route": "Santos → Rotterdam", "time": "1822 jours", "via": "Atlantique direct" } "route": "Shanghai → Le Havre",
"time": "3035 jours",
"via": "Suez"
},
{
"route": "Shanghai → Los Angeles",
"time": "1215 jours",
"via": "Pacifique direct"
},
{
"route": "Shanghai → New York",
"time": "3540 jours",
"via": "Suez ou Panama"
},
{
"route": "Rotterdam → New York",
"time": "1014 jours",
"via": "Atlantique direct"
},
{
"route": "Mumbai → Rotterdam",
"time": "1822 jours",
"via": "Suez"
},
{
"route": "Santos → Rotterdam",
"time": "1822 jours",
"via": "Atlantique direct"
}
], ],
"transitNote": "Note : Ces temps sont indicatifs et varient selon les rotations, transbordements et conditions.", "transitNote": "Note : Ces temps sont indicatifs et varient selon les rotations, transbordements et conditions.",
"freeTimeTitle": "Free Time (Jours Gratuits)", "freeTimeTitle": "Free Time (Jours Gratuits)",

View File

@ -29,6 +29,7 @@ const prefixPublicPaths = [
'/press', '/press',
'/contact', '/contact',
'/carrier', '/carrier',
'/admin/login',
'/pricing', '/pricing',
'/docs', '/docs',
'/privacy', '/privacy',
@ -71,7 +72,11 @@ export default function middleware(request: NextRequest) {
const token = request.cookies.get('accessToken')?.value; const token = request.cookies.get('accessToken')?.value;
if (!isPublicPath && !token) { if (!isPublicPath && !token) {
const loginUrl = new URL(`/${resolvedLocale}/login`, request.url); // The admin area has its own entry point — send unauthenticated visitors
// to the dedicated admin login instead of the regular one.
const isAdminArea = pathWithoutLocale === '/admin' || pathWithoutLocale.startsWith('/admin/');
const loginPath = isAdminArea ? '/admin/login' : '/login';
const loginUrl = new URL(`/${resolvedLocale}${loginPath}`, request.url);
loginUrl.searchParams.set('redirect', pathname); loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl); return NextResponse.redirect(loginUrl);
} }

File diff suppressed because it is too large Load Diff

View File

@ -42,9 +42,10 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"isomorphic-dompurify": "^3.16.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
"next": "14.0.4", "next": "^14.2.35",
"next-intl": "^4.9.1", "next-intl": "^4.9.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",

View File

@ -1,117 +0,0 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { Link, usePathname } from '@/i18n/navigation';
import {
Users,
Building2,
Package,
FileText,
BarChart3,
Settings,
ScrollText,
Newspaper,
type LucideIcon,
} from 'lucide-react';
interface AdminMenuItem {
key: string;
href: string;
icon: LucideIcon;
}
const adminMenuItems: AdminMenuItem[] = [
{ key: 'users', href: '/dashboard/admin/users', icon: Users },
{ key: 'organizations', href: '/dashboard/admin/organizations', icon: Building2 },
{ key: 'bookings', href: '/dashboard/admin/bookings', icon: Package },
{ key: 'documents', href: '/dashboard/admin/documents', icon: FileText },
{ key: 'csvRates', href: '/dashboard/admin/csv-rates', icon: BarChart3 },
{ key: 'blog', href: '/dashboard/admin/blog', icon: Newspaper },
{ key: 'logs', href: '/dashboard/admin/logs', icon: ScrollText },
];
export default function AdminPanelDropdown() {
const t = useTranslations('components.adminPanelDropdown');
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const pathname = usePathname();
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
useEffect(() => {
setIsOpen(false);
}, [pathname]);
const isActiveRoute = adminMenuItems.some(item => pathname.startsWith(item.href));
return (
<div className="relative" ref={dropdownRef}>
{/* Trigger Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex items-center w-full px-4 py-3 text-sm font-medium rounded-lg transition-colors ${
isActiveRoute ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-100'
}`}
>
<Settings className="mr-3 h-5 w-5" />
<span className="flex-1 text-left">{t('trigger')}</span>
<svg
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Dropdown Menu */}
{isOpen && (
<div className="absolute left-0 right-0 mt-2 bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden">
<div className="py-2">
{adminMenuItems.map(item => {
const isActive = pathname.startsWith(item.href);
const IconComponent = item.icon;
const name = t(`items.${item.key}` as any);
const description = t(`items.${item.key}Desc` as any);
return (
<Link
key={item.key}
href={item.href}
className={`flex items-start px-4 py-3 hover:bg-gray-50 transition-colors ${
isActive ? 'bg-blue-50' : ''
}`}
>
<IconComponent className="h-5 w-5 mr-3 mt-0.5 text-gray-500" />
<div className="flex-1">
<div
className={`text-sm font-medium ${isActive ? 'text-blue-700' : 'text-gray-900'}`}
>
{name}
</div>
<div className="text-xs text-gray-500 mt-0.5">{description}</div>
</div>
</Link>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@ -3,6 +3,7 @@
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Eye, EyeOff } from 'lucide-react';
import { Carrier, CarrierStatus, CreateCarrierInput, UpdateCarrierInput } from '@/types/carrier'; import { Carrier, CarrierStatus, CreateCarrierInput, UpdateCarrierInput } from '@/types/carrier';
interface CarrierFormProps { interface CarrierFormProps {
@ -25,6 +26,7 @@ export const CarrierForm: React.FC<CarrierFormProps> = ({ carrier, onSubmit, onC
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showApiKey, setShowApiKey] = useState(false);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -129,13 +131,24 @@ export const CarrierForm: React.FC<CarrierFormProps> = ({ carrier, onSubmit, onC
{/* API Key */} {/* API Key */}
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">API Key</label> <label className="block text-sm font-medium text-gray-700 mb-1">API Key</label>
<div className="relative">
<input <input
type="password" type={showApiKey ? 'text' : 'password'}
value={formData.apiKey} value={formData.apiKey}
onChange={e => setFormData({ ...formData, apiKey: e.target.value })} onChange={e => setFormData({ ...formData, apiKey: e.target.value })}
placeholder="Enter API key" placeholder="Enter API key"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/> />
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
tabIndex={-1}
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
>
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div> </div>
{/* Rate Limit */} {/* Rate Limit */}

View File

@ -41,9 +41,7 @@ export function useBookings(initialFilters?: BookingFilters) {
queryParams.append('pageSize', String(filters.pageSize || 20)); queryParams.append('pageSize', String(filters.pageSize || 20));
const response = await fetch(`/api/v1/bookings/advanced/search?${queryParams.toString()}`, { const response = await fetch(`/api/v1/bookings/advanced/search?${queryParams.toString()}`, {
headers: { credentials: 'include',
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
},
}); });
if (!response.ok) { if (!response.ok) {
@ -102,9 +100,9 @@ export function useBookings(initialFilters?: BookingFilters) {
try { try {
const response = await fetch('/api/v1/bookings/export', { const response = await fetch('/api/v1/bookings/export', {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
format: options.format, format: options.format,

View File

@ -108,11 +108,7 @@ export async function exportAuditLogs(params?: {
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/audit/export?${queryParams.toString()}`, `${process.env.NEXT_PUBLIC_API_URL}/api/v1/audit/export?${queryParams.toString()}`,
{ {
method: 'GET', method: 'GET',
headers: { credentials: 'include',
Authorization: `Bearer ${
typeof window !== 'undefined' ? localStorage.getItem('access_token') : ''
}`,
},
} }
); );

View File

@ -4,7 +4,7 @@
* Endpoints for user authentication and session management * Endpoints for user authentication and session management
*/ */
import { get, post, setAuthTokens, clearAuthTokens } from './client'; import { get, post, clearAuthTokens } from './client';
import type { import type {
RegisterRequest, RegisterRequest,
LoginRequest, LoginRequest,
@ -19,12 +19,8 @@ import type {
* POST /api/v1/auth/register * POST /api/v1/auth/register
*/ */
export async function register(data: RegisterRequest): Promise<AuthResponse> { export async function register(data: RegisterRequest): Promise<AuthResponse> {
const response = await post<AuthResponse>('/api/v1/auth/register', data, false); // Tokens are delivered as httpOnly cookies by the backend
return post<AuthResponse>('/api/v1/auth/register', data, false);
// Store tokens
setAuthTokens(response.accessToken, response.refreshToken);
return response;
} }
/** /**
@ -32,21 +28,18 @@ export async function register(data: RegisterRequest): Promise<AuthResponse> {
* POST /api/v1/auth/login * POST /api/v1/auth/login
*/ */
export async function login(data: LoginRequest & { rememberMe?: boolean }): Promise<AuthResponse> { export async function login(data: LoginRequest & { rememberMe?: boolean }): Promise<AuthResponse> {
const { rememberMe, ...loginData } = data; // Tokens are delivered as httpOnly cookies by the backend;
const response = await post<AuthResponse>('/api/v1/auth/login', loginData, false); // rememberMe drives cookie persistence server-side
return post<AuthResponse>('/api/v1/auth/login', data, false);
// Store tokens — localStorage if rememberMe, sessionStorage otherwise
setAuthTokens(response.accessToken, response.refreshToken, rememberMe ?? false);
return response;
} }
/** /**
* Refresh access token * Refresh access token
* POST /api/v1/auth/refresh * POST /api/v1/auth/refresh
*/ */
export async function refreshToken(data: RefreshTokenRequest): Promise<{ accessToken: string }> { export async function refreshToken(data?: RefreshTokenRequest): Promise<{ success: boolean }> {
return post<{ accessToken: string }>('/api/v1/auth/refresh', data, false); // The backend reads the httpOnly refresh cookie and rotates the cookies
return post<{ success: boolean }>('/api/v1/auth/refresh', data ?? {}, false);
} }
/** /**

View File

@ -181,11 +181,7 @@ export async function exportBookings(params: {
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/bookings/export?${queryParams.toString()}`, `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bookings/export?${queryParams.toString()}`,
{ {
method: 'GET', method: 'GET',
headers: { credentials: 'include',
Authorization: `Bearer ${
typeof window !== 'undefined' ? localStorage.getItem('access_token') : ''
}`,
},
} }
); );

View File

@ -1,47 +1,37 @@
/** /**
* API Client Base * API Client Base
* *
* Core HTTP client with authentication and error handling * Core HTTP client with authentication and error handling.
*
* Authentication relies on httpOnly cookies set by the backend
* (`accessToken` / `refreshToken`), so no token is ever stored in
* localStorage or readable from JavaScript (XSS mitigation).
* The non-httpOnly `xpeditis_session` flag cookie (which contains no
* token) tells the frontend whether a session exists.
*/ */
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
const SESSION_FLAG_COOKIE = 'xpeditis_session';
// Track if we're currently refreshing to avoid multiple simultaneous refresh requests // Track if we're currently refreshing to avoid multiple simultaneous refresh requests
let isRefreshing = false; let isRefreshing = false;
let refreshSubscribers: Array<(token: string) => void> = []; let refreshSubscribers: Array<() => void> = [];
/** /**
* Get authentication token checks localStorage first (remember me), then sessionStorage * Whether an authenticated session exists (based on the readable flag cookie
* set by the backend alongside the httpOnly token cookies).
*/ */
export function getAuthToken(): string | null { export function hasSession(): boolean {
if (typeof window === 'undefined') return null; if (typeof document === 'undefined') return false;
return localStorage.getItem('access_token') || sessionStorage.getItem('access_token'); return document.cookie.split('; ').some(cookie => cookie.startsWith(`${SESSION_FLAG_COOKIE}=`));
} }
/** /**
* Get refresh token checks localStorage first (remember me), then sessionStorage * Clear client-side auth state.
*/ * Token cookies are httpOnly and are cleared by the backend on logout;
export function getRefreshToken(): string | null { * this removes the user cache and any tokens left over from the legacy
if (typeof window === 'undefined') return null; * localStorage-based auth.
return localStorage.getItem('refresh_token') || sessionStorage.getItem('refresh_token');
}
/**
* Set authentication tokens.
* rememberMe=true localStorage (persists across browser sessions)
* rememberMe=false sessionStorage (cleared when browser closes)
*/
export function setAuthTokens(accessToken: string, refreshToken: string, rememberMe = false): void {
if (typeof window === 'undefined') return;
const storage = rememberMe ? localStorage : sessionStorage;
storage.setItem('access_token', accessToken);
storage.setItem('refresh_token', refreshToken);
// Sync to cookie so Next.js middleware can read it for route protection
document.cookie = `accessToken=${accessToken}; path=/; SameSite=Lax`;
}
/**
* Clear authentication tokens from both storages
*/ */
export function clearAuthTokens(): void { export function clearAuthTokens(): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@ -51,41 +41,39 @@ export function clearAuthTokens(): void {
sessionStorage.removeItem('access_token'); sessionStorage.removeItem('access_token');
sessionStorage.removeItem('refresh_token'); sessionStorage.removeItem('refresh_token');
sessionStorage.removeItem('user'); sessionStorage.removeItem('user');
// Expire the middleware cookie // Expire the legacy middleware cookie and the session flag (best effort —
// the backend clears the authoritative httpOnly cookies)
document.cookie = 'accessToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax'; document.cookie = 'accessToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax';
document.cookie = `${SESSION_FLAG_COOKIE}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`;
} }
/** /**
* Add subscriber to be notified when token is refreshed * Add subscriber to be notified when the session is refreshed
*/ */
function subscribeTokenRefresh(callback: (token: string) => void): void { function subscribeTokenRefresh(callback: () => void): void {
refreshSubscribers.push(callback); refreshSubscribers.push(callback);
} }
/** /**
* Notify all subscribers that token has been refreshed * Notify all subscribers that the session has been refreshed
*/ */
function onTokenRefreshed(token: string): void { function onTokenRefreshed(): void {
refreshSubscribers.forEach(callback => callback(token)); refreshSubscribers.forEach(callback => callback());
refreshSubscribers = []; refreshSubscribers = [];
} }
/** /**
* Refresh access token using refresh token * Refresh the session the backend reads the httpOnly refresh cookie and
* sets new token cookies on success.
*/ */
async function refreshAccessToken(): Promise<string> { async function refreshSession(): Promise<void> {
const refreshToken = getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
const response = await fetch(`${API_BASE_URL}/api/v1/auth/refresh`, { const response = await fetch(`${API_BASE_URL}/api/v1/auth/refresh`, {
method: 'POST', method: 'POST',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ refreshToken }), body: JSON.stringify({}),
}); });
if (!response.ok) { if (!response.ok) {
@ -95,54 +83,24 @@ async function refreshAccessToken(): Promise<string> {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.location.href = '/login'; window.location.href = '/login';
} }
throw new Error('Failed to refresh token'); throw new Error('Failed to refresh session');
} }
const data = await response.json();
const newAccessToken = data.accessToken;
// Update access token in the same storage that holds the refresh token
if (typeof window !== 'undefined') {
const storage = localStorage.getItem('refresh_token') ? localStorage : sessionStorage;
storage.setItem('access_token', newAccessToken);
document.cookie = `accessToken=${newAccessToken}; path=/; SameSite=Lax`;
}
return newAccessToken;
} }
/** /**
* Create headers with authentication * Create headers (auth is carried by httpOnly cookies, not headers)
*/ */
export function createHeaders(includeAuth = true): HeadersInit { export function createHeaders(_includeAuth = true): HeadersInit {
const headers: HeadersInit = { return {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}; };
if (includeAuth) {
const token = getAuthToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
return headers;
} }
/** /**
* Create headers for multipart form data * Create headers for multipart form data
*/ */
export function createMultipartHeaders(includeAuth = true): HeadersInit { export function createMultipartHeaders(_includeAuth = true): HeadersInit {
const headers: HeadersInit = {}; return {};
if (includeAuth) {
const token = getAuthToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
return headers;
} }
/** /**
@ -160,7 +118,7 @@ export class ApiError extends Error {
} }
/** /**
* Make API request with automatic token refresh on 401 * Make API request with automatic session refresh on 401
*/ */
export async function apiRequest<T>( export async function apiRequest<T>(
endpoint: string, endpoint: string,
@ -171,6 +129,7 @@ export async function apiRequest<T>(
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
credentials: 'include',
headers: { headers: {
...options.headers, ...options.headers,
}, },
@ -184,10 +143,8 @@ export async function apiRequest<T>(
endpoint.includes('/auth/refresh'); endpoint.includes('/auth/refresh');
if (response.status === 401 && !isRetry && !isAuthEndpoint) { if (response.status === 401 && !isRetry && !isAuthEndpoint) {
// Check if we have a refresh token if (!hasSession()) {
const refreshToken = getRefreshToken(); // No session, redirect to login
if (!refreshToken) {
// No refresh token, redirect to login
clearAuthTokens(); clearAuthTokens();
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.location.href = '/login'; window.location.href = '/login';
@ -195,46 +152,21 @@ export async function apiRequest<T>(
throw new ApiError('Session expired', 401); throw new ApiError('Session expired', 401);
} }
// Try to refresh the token // Try to refresh the session (cookies are updated server-side)
try { try {
if (!isRefreshing) { if (!isRefreshing) {
isRefreshing = true; isRefreshing = true;
const newAccessToken = await refreshAccessToken(); await refreshSession();
isRefreshing = false; isRefreshing = false;
onTokenRefreshed(newAccessToken); onTokenRefreshed();
// Retry the original request with new token return apiRequest<T>(endpoint, options, true);
const newHeaders = { ...options.headers };
if (newHeaders && typeof newHeaders === 'object' && 'Authorization' in newHeaders) {
(newHeaders as any)['Authorization'] = `Bearer ${newAccessToken}`;
}
return apiRequest<T>(
endpoint,
{
...options,
headers: newHeaders,
},
true
);
} else { } else {
// Already refreshing, wait for the new token // Already refreshing, wait for it to complete
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
subscribeTokenRefresh(async (newAccessToken: string) => { subscribeTokenRefresh(async () => {
const newHeaders = { ...options.headers };
if (newHeaders && typeof newHeaders === 'object' && 'Authorization' in newHeaders) {
(newHeaders as any)['Authorization'] = `Bearer ${newAccessToken}`;
}
try { try {
const result = await apiRequest<T>( const result = await apiRequest<T>(endpoint, options, true);
endpoint,
{
...options,
headers: newHeaders,
},
true
);
resolve(result); resolve(result);
} catch (error) { } catch (error) {
reject(error); reject(error);
@ -320,23 +252,20 @@ export async function upload<T>(
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
credentials: 'include',
headers: createMultipartHeaders(includeAuth), headers: createMultipartHeaders(includeAuth),
body: formData, body: formData,
}); });
// Handle 401 Unauthorized for file uploads // Handle 401 Unauthorized for file uploads
if (response.status === 401) { if (response.status === 401 && hasSession()) {
const refreshToken = getRefreshToken();
if (refreshToken) {
try { try {
const newAccessToken = await refreshAccessToken(); await refreshSession();
// Retry upload with new token // Retry upload with refreshed cookies
const retryResponse = await fetch(url, { const retryResponse = await fetch(url, {
method: 'POST', method: 'POST',
headers: { credentials: 'include',
...createMultipartHeaders(includeAuth), headers: createMultipartHeaders(includeAuth),
Authorization: `Bearer ${newAccessToken}`,
},
body: formData, body: formData,
}); });
@ -358,7 +287,6 @@ export async function upload<T>(
throw refreshError; throw refreshError;
} }
} }
}
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({})); const error = await response.json().catch(() => ({}));
@ -384,22 +312,19 @@ export async function download(
const response = await fetch(url, { const response = await fetch(url, {
method: 'GET', method: 'GET',
credentials: 'include',
headers: createHeaders(includeAuth), headers: createHeaders(includeAuth),
}); });
// Handle 401 Unauthorized for downloads // Handle 401 Unauthorized for downloads
if (response.status === 401) { if (response.status === 401 && hasSession()) {
const refreshToken = getRefreshToken();
if (refreshToken) {
try { try {
const newAccessToken = await refreshAccessToken(); await refreshSession();
// Retry download with new token // Retry download with refreshed cookies
const retryResponse = await fetch(url, { const retryResponse = await fetch(url, {
method: 'GET', method: 'GET',
headers: { credentials: 'include',
...createHeaders(includeAuth), headers: createHeaders(includeAuth),
Authorization: `Bearer ${newAccessToken}`,
},
}); });
if (!retryResponse.ok) { if (!retryResponse.ok) {
@ -424,7 +349,6 @@ export async function download(
throw refreshError; throw refreshError;
} }
} }
}
if (!response.ok) { if (!response.ok) {
throw new ApiError(`Download failed: ${response.statusText}`, response.status); throw new ApiError(`Download failed: ${response.statusText}`, response.status);

View File

@ -14,27 +14,12 @@ import {
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
/** /**
* Get authentication token from localStorage * Create headers auth is carried by httpOnly cookies (credentials: 'include')
*/
function getAuthToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('access_token');
}
/**
* Create headers with authentication
*/ */
function createHeaders(): HeadersInit { function createHeaders(): HeadersInit {
const headers: HeadersInit = { return {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}; };
const token = getAuthToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
} }
/** /**
@ -45,6 +30,7 @@ export async function searchCsvRates(
): Promise<CsvRateSearchResponse> { ): Promise<CsvRateSearchResponse> {
const response = await fetch(`${API_BASE_URL}/api/v1/rates/search-csv`, { const response = await fetch(`${API_BASE_URL}/api/v1/rates/search-csv`, {
method: 'POST', method: 'POST',
credentials: 'include',
headers: createHeaders(), headers: createHeaders(),
body: JSON.stringify(request), body: JSON.stringify(request),
}); });
@ -63,6 +49,7 @@ export async function searchCsvRates(
export async function getAvailableCompanies(): Promise<AvailableCompanies> { export async function getAvailableCompanies(): Promise<AvailableCompanies> {
const response = await fetch(`${API_BASE_URL}/api/v1/rates/companies`, { const response = await fetch(`${API_BASE_URL}/api/v1/rates/companies`, {
method: 'GET', method: 'GET',
credentials: 'include',
headers: createHeaders(), headers: createHeaders(),
}); });
@ -79,6 +66,7 @@ export async function getAvailableCompanies(): Promise<AvailableCompanies> {
export async function getFilterOptions(): Promise<FilterOptions> { export async function getFilterOptions(): Promise<FilterOptions> {
const response = await fetch(`${API_BASE_URL}/api/v1/rates/filters/options`, { const response = await fetch(`${API_BASE_URL}/api/v1/rates/filters/options`, {
method: 'GET', method: 'GET',
credentials: 'include',
headers: createHeaders(), headers: createHeaders(),
}); });

View File

@ -53,11 +53,7 @@ export interface GdprDataExportResponse {
export async function requestDataExport(): Promise<Blob> { export async function requestDataExport(): Promise<Blob> {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export`, { const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export`, {
method: 'GET', method: 'GET',
headers: { credentials: 'include',
Authorization: `Bearer ${
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
}`,
},
}); });
if (!response.ok) { if (!response.ok) {
@ -74,11 +70,7 @@ export async function requestDataExport(): Promise<Blob> {
export async function requestDataExportCSV(): Promise<Blob> { export async function requestDataExportCSV(): Promise<Blob> {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export/csv`, { const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export/csv`, {
method: 'GET', method: 'GET',
headers: { credentials: 'include',
Authorization: `Bearer ${
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
}`,
},
}); });
if (!response.ok) { if (!response.ok) {
@ -96,11 +88,9 @@ export async function requestDataExportCSV(): Promise<Blob> {
export async function requestAccountDeletion(confirmEmail: string, reason?: string): Promise<void> { export async function requestAccountDeletion(confirmEmail: string, reason?: string): Promise<void> {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/delete-account`, { const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/delete-account`, {
method: 'DELETE', method: 'DELETE',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
}`,
}, },
body: JSON.stringify({ confirmEmail, reason }), body: JSON.stringify({ confirmEmail, reason }),
}); });

View File

@ -10,8 +10,7 @@
// Base client utilities // Base client utilities
export { export {
getAuthToken, hasSession,
setAuthTokens,
clearAuthTokens, clearAuthTokens,
createHeaders, createHeaders,
apiRequest, apiRequest,

View File

@ -34,12 +34,6 @@ export interface PortSearchResponse {
* Search ports by query (autocomplete) * Search ports by query (autocomplete)
*/ */
export async function searchPorts(params: PortSearchParams): Promise<PortSearchResponse> { export async function searchPorts(params: PortSearchParams): Promise<PortSearchResponse> {
// Use the same key as the rest of the app: 'access_token' with underscore
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
if (!token) {
throw new Error('No access token found');
}
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
query: params.query, query: params.query,
}); });
@ -54,9 +48,9 @@ export async function searchPorts(params: PortSearchParams): Promise<PortSearchR
const response = await fetch(`${API_BASE_URL}/api/v1/ports/search?${queryParams.toString()}`, { const response = await fetch(`${API_BASE_URL}/api/v1/ports/search?${queryParams.toString()}`, {
method: 'GET', method: 'GET',
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
}, },
}); });

View File

@ -14,7 +14,7 @@ import {
logout as apiLogout, logout as apiLogout,
getCurrentUser, getCurrentUser,
} from '../api/auth'; } from '../api/auth';
import { getAuthToken } from '../api/client'; import { hasSession, clearAuthTokens } from '../api/client';
import type { UserPayload } from '@/types/api'; import type { UserPayload } from '@/types/api';
interface AuthContextType { interface AuthContextType {
@ -45,16 +45,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const router = useRouter(); const router = useRouter();
// Helper function to check if user is authenticated // Helper function to check if user is authenticated (session flag cookie)
const isAuthenticated = () => { const isAuthenticated = () => {
return !!getAuthToken(); return hasSession();
};
// Helper function to get stored user
const getStoredUser = (): UserPayload | null => {
if (typeof window === 'undefined') return null;
const storedUser = localStorage.getItem('user');
return storedUser ? JSON.parse(storedUser) : null;
}; };
useEffect(() => { useEffect(() => {
@ -62,31 +55,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const checkAuth = async () => { const checkAuth = async () => {
try { try {
if (isAuthenticated()) { if (isAuthenticated()) {
// Try to fetch current user from API (will auto-refresh token if expired) // Try to fetch current user from API (will auto-refresh the session if expired)
try { try {
const currentUser = await getCurrentUser(); const currentUser = await getCurrentUser();
setUser(currentUser); setUser(currentUser);
// Update stored user
localStorage.setItem('user', JSON.stringify(currentUser));
} catch (apiError) { } catch (apiError) {
console.error('Failed to fetch user from API:', apiError); console.error('Failed to fetch user from API:', apiError);
// If API fails after token refresh attempt, clear everything // If API fails after session refresh attempt, clear everything
if (typeof window !== 'undefined') { clearAuthTokens();
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
}
setUser(null); setUser(null);
} }
} }
} catch (error) { } catch (error) {
console.error('Auth check failed, clearing tokens:', error); console.error('Auth check failed, clearing session:', error);
// Token invalid or no user data, clear storage clearAuthTokens();
if (typeof window !== 'undefined') {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
}
setUser(null); setUser(null);
} finally { } finally {
setLoading(false); setLoading(false);
@ -122,14 +104,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
) => { ) => {
try { try {
await apiLogin({ email, password, rememberMe }); await apiLogin({ email, password, rememberMe });
// Fetch complete user profile after login // Fetch complete user profile after login (session lives in httpOnly cookies)
const currentUser = await getCurrentUser(); const currentUser = await getCurrentUser();
setUser(currentUser); setUser(currentUser);
// Store user in the same storage as the tokens
if (typeof window !== 'undefined') {
const storage = rememberMe ? localStorage : sessionStorage;
storage.setItem('user', JSON.stringify(currentUser));
}
router.push(redirectTo); router.push(redirectTo);
} catch (error) { } catch (error) {
throw error; throw error;
@ -144,14 +121,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
organizationId: string; organizationId: string;
}) => { }) => {
try { try {
const response = await apiRegister(data); await apiRegister(data);
// Fetch complete user profile after registration // Fetch complete user profile after registration
const currentUser = await getCurrentUser(); const currentUser = await getCurrentUser();
setUser(currentUser); setUser(currentUser);
// Store user in localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('user', JSON.stringify(currentUser));
}
router.push('/dashboard'); router.push('/dashboard');
} catch (error) { } catch (error) {
throw error; throw error;
@ -163,10 +136,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await apiLogout(); await apiLogout();
} finally { } finally {
setUser(null); setUser(null);
// Clear user from localStorage
if (typeof window !== 'undefined') {
localStorage.removeItem('user');
}
router.push('/login'); router.push('/login');
} }
}; };
@ -175,9 +144,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
try { try {
const currentUser = await getCurrentUser(); const currentUser = await getCurrentUser();
setUser(currentUser); setUser(currentUser);
if (typeof window !== 'undefined') {
localStorage.setItem('user', JSON.stringify(currentUser));
}
} catch (error) { } catch (error) {
console.error('Failed to refresh user:', error); console.error('Failed to refresh user:', error);
} }

View File

@ -13,7 +13,7 @@ import {
updateConsentPreferences, updateConsentPreferences,
type CookiePreferences, type CookiePreferences,
} from '../api/gdpr'; } from '../api/gdpr';
import { getAuthToken } from '../api/client'; import { hasSession } from '../api/client';
const STORAGE_KEY = 'cookieConsent'; const STORAGE_KEY = 'cookieConsent';
const STORAGE_DATE_KEY = 'cookieConsentDate'; const STORAGE_DATE_KEY = 'cookieConsentDate';
@ -49,7 +49,7 @@ export function CookieProvider({ children }: { children: React.ReactNode }) {
// Check if user is authenticated // Check if user is authenticated
const isAuthenticated = useCallback(() => { const isAuthenticated = useCallback(() => {
return !!getAuthToken(); return hasSession();
}, []); }, []);
// Load preferences from localStorage // Load preferences from localStorage

View File

@ -27,7 +27,6 @@ export interface RegisterRequest {
firstName: string; firstName: string;
lastName: string; lastName: string;
invitationToken?: string; // For invited users (token-based) invitationToken?: string; // For invited users (token-based)
organizationId?: string; // For invited users (ID-based)
organization?: RegisterOrganizationData; // For new users organization?: RegisterOrganizationData; // For new users
} }

View File

@ -103,8 +103,8 @@ services:
REDIS_PASSWORD: xpeditis_redis_password REDIS_PASSWORD: xpeditis_redis_password
REDIS_DB: 0 REDIS_DB: 0
# JWT # JWT (must be at least 32 characters — enforced at startup)
JWT_SECRET: dev-secret-jwt-key-for-docker JWT_SECRET: dev-secret-jwt-key-for-docker-local-only-0123456789
JWT_ACCESS_EXPIRATION: 15m JWT_ACCESS_EXPIRATION: 15m
JWT_REFRESH_EXPIRATION: 7d JWT_REFRESH_EXPIRATION: 7d