Compare commits
No commits in common. "8f1afb0098f82df7f1acd9a9797ef946cc6298d4" and "3a2a14b5b7b1b449883db6c06530019a4c774c9a" have entirely different histories.
8f1afb0098
...
3a2a14b5b7
@ -18,19 +18,11 @@ REDIS_PORT=6379
|
||||
REDIS_PASSWORD=xpeditis_redis_password
|
||||
REDIS_DB=0
|
||||
|
||||
# JWT (JWT_SECRET must be at least 32 characters)
|
||||
# JWT
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||
JWT_ACCESS_EXPIRATION=15m
|
||||
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
|
||||
GOOGLE_CLIENT_ID=your-google-client-id
|
||||
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||
|
||||
@ -74,7 +74,7 @@ EXPOSE 4000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:4000/api/v1/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
|
||||
CMD node -e "require('http').get('http://localhost:4000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
3384
apps/backend/package-lock.json
generated
3384
apps/backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -51,7 +51,6 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"compression": "^1.8.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"csv-parse": "^6.1.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"handlebars": "^4.7.8",
|
||||
@ -88,7 +87,6 @@
|
||||
"@nestjs/testing": "^10.2.10",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/multer": "^2.0.0",
|
||||
|
||||
@ -39,7 +39,6 @@ import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.mod
|
||||
|
||||
// Import global guards
|
||||
import { ApiKeyOrJwtGuard } from './application/guards/api-key-or-jwt.guard';
|
||||
import { HealthController } from './application/controllers/health.controller';
|
||||
import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
||||
|
||||
@Module({
|
||||
@ -60,14 +59,9 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
||||
REDIS_HOST: Joi.string().required(),
|
||||
REDIS_PORT: Joi.number().default(6379),
|
||||
REDIS_PASSWORD: Joi.string().required(),
|
||||
JWT_SECRET: Joi.string().min(32).required(),
|
||||
JWT_SECRET: Joi.string().required(),
|
||||
JWT_ACCESS_EXPIRATION: Joi.string().default('15m'),
|
||||
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_HOST: Joi.string().required(),
|
||||
SMTP_PORT: Joi.number().default(2525),
|
||||
@ -191,7 +185,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
||||
ApiKeysModule,
|
||||
LogsModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
controllers: [],
|
||||
providers: [
|
||||
// Global authentication guard — supports both JWT (frontend) and API key (Gold/Platinium)
|
||||
// All routes are protected by default, use @Public() to bypass
|
||||
|
||||
@ -22,7 +22,6 @@ import { InvitationService } from '../services/invitation.service';
|
||||
import { InvitationsController } from '../controllers/invitations.controller';
|
||||
import { EmailModule } from '../../infrastructure/email/email.module';
|
||||
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -54,9 +53,6 @@ import { AuditModule } from '../audit/audit.module';
|
||||
|
||||
// Subscriptions module for license checks
|
||||
SubscriptionsModule,
|
||||
|
||||
// Audit module for login/logout tracking
|
||||
AuditModule,
|
||||
],
|
||||
controllers: [AuthController, InvitationsController],
|
||||
providers: [
|
||||
|
||||
@ -21,7 +21,6 @@ import {
|
||||
} from '@domain/ports/out/organization.repository';
|
||||
import { Organization } from '@domain/entities/organization.entity';
|
||||
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 { RegisterOrganizationDto } from '../dto/auth-login.dto';
|
||||
import { SubscriptionService } from '../services/subscription.service';
|
||||
@ -35,7 +34,6 @@ export interface JwtPayload {
|
||||
plan?: string; // subscription plan (BRONZE, SILVER, GOLD, PLATINIUM)
|
||||
planFeatures?: string[]; // plan feature flags
|
||||
type: 'access' | 'refresh';
|
||||
rememberMe?: boolean; // drives auth cookie persistence across refreshes
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@ -49,8 +47,6 @@ export class AuthService {
|
||||
private readonly organizationRepository: OrganizationRepository,
|
||||
@Inject(EMAIL_PORT)
|
||||
private readonly emailService: EmailPort,
|
||||
@Inject(CACHE_PORT)
|
||||
private readonly cache: CachePort,
|
||||
@InjectRepository(PasswordResetTokenOrmEntity)
|
||||
private readonly passwordResetTokenRepository: Repository<PasswordResetTokenOrmEntity>,
|
||||
private readonly jwtService: JwtService,
|
||||
@ -97,11 +93,6 @@ export class AuthService {
|
||||
// - Otherwise, default to USER
|
||||
let userRole: UserRole;
|
||||
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;
|
||||
} else if (organizationData) {
|
||||
// User creating a new organization becomes MANAGER
|
||||
@ -155,8 +146,7 @@ export class AuthService {
|
||||
*/
|
||||
async login(
|
||||
email: string,
|
||||
password: string,
|
||||
rememberMe = false
|
||||
password: string
|
||||
): Promise<{ accessToken: string; refreshToken: string; user: any }> {
|
||||
this.logger.log(`Login attempt for: ${email}`);
|
||||
|
||||
@ -176,7 +166,7 @@ export class AuthService {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const tokens = await this.generateTokens(user, rememberMe);
|
||||
const tokens = await this.generateTokens(user);
|
||||
|
||||
this.logger.log(`User logged in successfully: ${email}`);
|
||||
|
||||
@ -198,7 +188,7 @@ export class AuthService {
|
||||
*/
|
||||
async refreshAccessToken(
|
||||
refreshToken: string
|
||||
): Promise<{ accessToken: string; refreshToken: string; rememberMe: boolean }> {
|
||||
): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
|
||||
secret: this.configService.get('JWT_SECRET'),
|
||||
@ -208,78 +198,23 @@ export class AuthService {
|
||||
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);
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('User not found or inactive');
|
||||
}
|
||||
|
||||
const rememberMe = payload.rememberMe === true;
|
||||
const tokens = await this.generateTokens(user, rememberMe);
|
||||
const tokens = await this.generateTokens(user);
|
||||
|
||||
this.logger.log(`Access token refreshed for user: ${user.email}`);
|
||||
|
||||
return { ...tokens, rememberMe };
|
||||
return tokens;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Token refresh failed: ${error?.message || 'Unknown error'}`);
|
||||
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
|
||||
*/
|
||||
@ -299,15 +234,13 @@ export class AuthService {
|
||||
{ usedAt: new Date() }
|
||||
);
|
||||
|
||||
// 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
|
||||
// Generate a secure random token
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const tokenHash = this.hashResetToken(token);
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||
|
||||
await this.passwordResetTokenRepository.save({
|
||||
userId: user.id,
|
||||
token: tokenHash,
|
||||
token,
|
||||
expiresAt,
|
||||
usedAt: null,
|
||||
});
|
||||
@ -321,9 +254,7 @@ export class AuthService {
|
||||
* Reset password using token from email
|
||||
*/
|
||||
async resetPassword(token: string, newPassword: string): Promise<void> {
|
||||
const resetToken = await this.passwordResetTokenRepository.findOne({
|
||||
where: { token: this.hashResetToken(token) },
|
||||
});
|
||||
const resetToken = await this.passwordResetTokenRepository.findOne({ where: { token } });
|
||||
|
||||
if (!resetToken) {
|
||||
throw new BadRequestException('Token de réinitialisation invalide ou expiré');
|
||||
@ -362,10 +293,6 @@ export class AuthService {
|
||||
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
|
||||
*/
|
||||
@ -382,10 +309,7 @@ export class AuthService {
|
||||
/**
|
||||
* Generate access and refresh tokens
|
||||
*/
|
||||
private async generateTokens(
|
||||
user: User,
|
||||
rememberMe = false
|
||||
): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
// ADMIN users always get PLATINIUM plan with no expiration
|
||||
let plan = 'BRONZE';
|
||||
let planFeatures: string[] = [];
|
||||
@ -431,7 +355,6 @@ export class AuthService {
|
||||
plan,
|
||||
planFeatures,
|
||||
type: 'refresh',
|
||||
rememberMe,
|
||||
};
|
||||
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
|
||||
@ -35,11 +35,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
private readonly authService: AuthService
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
// httpOnly cookie set by the auth endpoints (XSS-safe storage)
|
||||
(req: { cookies?: Record<string, string> }) => req?.cookies?.accessToken ?? null,
|
||||
]),
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_SECRET'),
|
||||
});
|
||||
|
||||
@ -8,15 +8,10 @@ import {
|
||||
Get,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import {
|
||||
LoginDto,
|
||||
@ -34,26 +29,6 @@ import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||
import { UserMapper } from '../mappers/user.mapper';
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication Controller
|
||||
@ -74,53 +49,9 @@ export class AuthController {
|
||||
private readonly authService: AuthService,
|
||||
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
||||
private readonly invitationService: InvitationService,
|
||||
private readonly auditService: AuditService,
|
||||
@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
|
||||
*
|
||||
@ -130,7 +61,6 @@ export class AuthController {
|
||||
* @returns Access token, refresh token, and user info
|
||||
*/
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
@Post('register')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
@ -150,10 +80,7 @@ export class AuthController {
|
||||
status: 400,
|
||||
description: 'Validation error (invalid email, weak password, etc.)',
|
||||
})
|
||||
async register(
|
||||
@Body() dto: RegisterDto,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
): Promise<AuthResponseDto> {
|
||||
async register(@Body() dto: RegisterDto): Promise<AuthResponseDto> {
|
||||
// If invitation token is provided, verify and use it
|
||||
let invitationOrganizationId: string | undefined;
|
||||
let invitationRole: string | undefined;
|
||||
@ -174,14 +101,12 @@ export class AuthController {
|
||||
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(
|
||||
dto.email,
|
||||
dto.password,
|
||||
dto.firstName,
|
||||
dto.lastName,
|
||||
invitationOrganizationId,
|
||||
invitationOrganizationId || dto.organizationId,
|
||||
dto.organization,
|
||||
invitationRole
|
||||
);
|
||||
@ -191,8 +116,6 @@ export class AuthController {
|
||||
await this.invitationService.markInvitationAsUsed(dto.invitationToken);
|
||||
}
|
||||
|
||||
this.setAuthCookies(res, result, false);
|
||||
|
||||
return {
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
@ -209,7 +132,6 @@ export class AuthController {
|
||||
* @returns Access token, refresh token, and user info
|
||||
*/
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
@ -225,61 +147,14 @@ export class AuthController {
|
||||
status: 401,
|
||||
description: 'Invalid credentials or inactive account',
|
||||
})
|
||||
async login(
|
||||
@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'}`);
|
||||
async login(@Body() dto: LoginDto): Promise<AuthResponseDto> {
|
||||
const result = await this.authService.login(dto.email, dto.password);
|
||||
|
||||
return {
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -291,7 +166,6 @@ export class AuthController {
|
||||
* @returns New access token
|
||||
*/
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 20, ttl: 60000 } })
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
@ -301,10 +175,10 @@ export class AuthController {
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Token refreshed successfully — new tokens are set as httpOnly cookies',
|
||||
description: 'Token refreshed successfully',
|
||||
schema: {
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
accessToken: { type: 'string', example: 'eyJhbGciOiJIUzI1NiIs...' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -312,40 +186,27 @@ export class AuthController {
|
||||
status: 401,
|
||||
description: 'Invalid or expired refresh token',
|
||||
})
|
||||
async refresh(
|
||||
@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;
|
||||
async refresh(@Body() dto: RefreshTokenDto): Promise<{ accessToken: string }> {
|
||||
const result = await this.authService.refreshAccessToken(dto.refreshToken);
|
||||
|
||||
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 };
|
||||
return { accessToken: result.accessToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout
|
||||
* Logout (placeholder)
|
||||
*
|
||||
* Revokes the refresh token (Redis blacklist) and clears the auth cookies.
|
||||
* The access token naturally expires within 15 minutes.
|
||||
* Currently a no-op endpoint. With JWT, logout is typically handled client-side
|
||||
* by removing tokens. For more security, implement token blacklisting with Redis.
|
||||
*
|
||||
* @returns Success message
|
||||
*/
|
||||
@Public()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: 'Logout',
|
||||
description: 'Revoke the refresh token and clear authentication cookies.',
|
||||
description: 'Logout the current user. Currently handled client-side by removing tokens.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@ -356,28 +217,9 @@ export class AuthController {
|
||||
},
|
||||
},
|
||||
})
|
||||
async logout(
|
||||
@Req() req: Request,
|
||||
@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'}`);
|
||||
}
|
||||
|
||||
async logout(): Promise<{ message: string }> {
|
||||
// TODO: Implement token blacklisting with Redis for more security
|
||||
// For now, logout is handled client-side by removing tokens
|
||||
return { message: 'Logout successful' };
|
||||
}
|
||||
|
||||
@ -385,7 +227,6 @@ export class AuthController {
|
||||
* Contact form — forwards message to contact@xpeditis.com
|
||||
*/
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
||||
@Post('contact')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
@ -404,13 +245,7 @@ export class AuthController {
|
||||
other: 'Autre',
|
||||
};
|
||||
|
||||
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 subjectLabel = subjectLabels[dto.subject] || dto.subject;
|
||||
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
@ -421,14 +256,14 @@ export class AuthController {
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<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;">${firstName} ${lastName}</td>
|
||||
<td style="padding: 8px 0; color: #222; font-weight: bold; font-size: 14px;">${dto.firstName} ${dto.lastName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666; font-size: 14px;">Email</td>
|
||||
<td style="padding: 8px 0; font-size: 14px;"><a href="mailto:${email}" style="color: #34CCCD;">${email}</a></td>
|
||||
<td style="padding: 8px 0; font-size: 14px;"><a href="mailto:${dto.email}" style="color: #34CCCD;">${dto.email}</a></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>` : ''}
|
||||
${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>` : ''}
|
||||
${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>` : ''}
|
||||
${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>` : ''}
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666; font-size: 14px;">Sujet</td>
|
||||
<td style="padding: 8px 0; color: #222; font-size: 14px;">${subjectLabel}</td>
|
||||
@ -436,7 +271,7 @@ export class AuthController {
|
||||
</table>
|
||||
<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: #222; font-size: 14px; white-space: pre-wrap; margin: 0;">${message}</p>
|
||||
<p style="color: #222; font-size: 14px; white-space: pre-wrap; margin: 0;">${dto.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background: #f0f0f0; padding: 12px 24px; border-radius: 0 0 8px 8px; text-align: center;">
|
||||
@ -449,7 +284,7 @@ export class AuthController {
|
||||
await this.emailService.send({
|
||||
to: 'contact@xpeditis.com',
|
||||
replyTo: dto.email,
|
||||
subject: `[Contact] ${subjectLabels[dto.subject] || dto.subject} — ${dto.firstName} ${dto.lastName}`,
|
||||
subject: `[Contact] ${subjectLabel} — ${dto.firstName} ${dto.lastName}`,
|
||||
html,
|
||||
});
|
||||
} catch (error) {
|
||||
@ -466,7 +301,6 @@ export class AuthController {
|
||||
* Forgot password — sends reset email
|
||||
*/
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
||||
@Post('forgot-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
@ -485,7 +319,6 @@ export class AuthController {
|
||||
* Reset password using token from email
|
||||
*/
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
@Post('reset-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
|
||||
@ -248,7 +248,18 @@ export class RegisterDto {
|
||||
invitationToken?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Organization data (required if invitationToken is not provided)',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||
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,
|
||||
required: false,
|
||||
})
|
||||
@ -293,11 +304,10 @@ export class AuthResponseDto {
|
||||
}
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiPropertyOptional({
|
||||
@ApiProperty({
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
description: 'Refresh token (optional — the httpOnly cookie is preferred)',
|
||||
description: 'Refresh token',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
refreshToken?: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
@ -8,7 +8,6 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as argon2 from 'argon2';
|
||||
import * as crypto from 'crypto';
|
||||
import { CsvBooking, CsvBookingStatus, DocumentType } from '@domain/entities/csv-booking.entity';
|
||||
import { PortCode } from '@domain/value-objects/port-code.vo';
|
||||
import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
||||
@ -82,54 +81,17 @@ export class CsvBookingService {
|
||||
const year = new Date().getFullYear();
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // No 0, O, 1, I for clarity
|
||||
let code = '';
|
||||
const randomBytes = crypto.randomBytes(6);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code += chars.charAt(randomBytes[i] % chars.length);
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return `XPD-${year}-${code}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Extract the password from booking number (last 6 characters)
|
||||
*/
|
||||
private deriveDocumentPassword(bookingId: string): string {
|
||||
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;
|
||||
private extractPasswordFromBookingNumber(bookingNumber: string): string {
|
||||
return bookingNumber.split('-').pop() || bookingNumber.slice(-6);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -152,7 +114,7 @@ export class CsvBookingService {
|
||||
const confirmationToken = uuidv4();
|
||||
const bookingId = uuidv4();
|
||||
const bookingNumber = this.generateBookingNumber();
|
||||
const documentPassword = this.deriveDocumentPassword(bookingId);
|
||||
const documentPassword = this.extractPasswordFromBookingNumber(bookingNumber);
|
||||
|
||||
// Hash the password for storage
|
||||
const passwordHash = await argon2.hash(documentPassword);
|
||||
@ -330,7 +292,9 @@ export class CsvBookingService {
|
||||
where: { id: bookingId },
|
||||
});
|
||||
const bookingNumber = ormBooking?.bookingNumber;
|
||||
const documentPassword = await this.syncDocumentPassword(booking.id);
|
||||
const documentPassword = bookingNumber
|
||||
? this.extractPasswordFromBookingNumber(bookingNumber)
|
||||
: undefined;
|
||||
|
||||
// NOW send email to carrier
|
||||
try {
|
||||
@ -500,7 +464,9 @@ export class CsvBookingService {
|
||||
where: { id: bookingId },
|
||||
});
|
||||
const bookingNumber = ormBooking?.bookingNumber;
|
||||
const documentPassword = await this.syncDocumentPassword(booking.id);
|
||||
const documentPassword = bookingNumber
|
||||
? this.extractPasswordFromBookingNumber(bookingNumber)
|
||||
: undefined;
|
||||
|
||||
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
|
||||
bookingId: booking.id,
|
||||
@ -555,7 +521,9 @@ export class CsvBookingService {
|
||||
where: { id: bookingId },
|
||||
});
|
||||
const bookingNumber = ormBooking?.bookingNumber;
|
||||
const documentPassword = await this.syncDocumentPassword(booking.id);
|
||||
const documentPassword = bookingNumber
|
||||
? this.extractPasswordFromBookingNumber(bookingNumber)
|
||||
: undefined;
|
||||
|
||||
// Send email to carrier
|
||||
try {
|
||||
@ -816,7 +784,9 @@ export class CsvBookingService {
|
||||
|
||||
// Extract password from booking number for the email
|
||||
const bookingNumber = ormBooking?.bookingNumber;
|
||||
const documentPassword = await this.syncDocumentPassword(booking.id);
|
||||
const documentPassword = bookingNumber
|
||||
? this.extractPasswordFromBookingNumber(bookingNumber)
|
||||
: undefined;
|
||||
|
||||
// Send document access email to carrier
|
||||
try {
|
||||
|
||||
@ -107,6 +107,21 @@ export const corsConfig = {
|
||||
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
|
||||
*/
|
||||
@ -140,36 +155,19 @@ export const fileUploadConfig = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 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").
|
||||
* JWT Configuration
|
||||
*/
|
||||
export const AUTH_COOKIE_NAMES = {
|
||||
accessToken: 'accessToken',
|
||||
refreshToken: 'refreshToken',
|
||||
/** Non-httpOnly flag the frontend reads to know a session exists (contains no token) */
|
||||
session: 'xpeditis_session',
|
||||
} as const;
|
||||
|
||||
export function authCookieOptions(options?: { maxAgeMs?: number; httpOnly?: boolean }): {
|
||||
httpOnly: boolean;
|
||||
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 } : {}),
|
||||
export const jwtConfig = {
|
||||
accessToken: {
|
||||
secret: process.env.JWT_SECRET || 'change-this-secret',
|
||||
expiresIn: '15m', // 15 minutes
|
||||
},
|
||||
refreshToken: {
|
||||
secret: process.env.JWT_REFRESH_SECRET || 'change-this-refresh-secret',
|
||||
expiresIn: '7d', // 7 days
|
||||
},
|
||||
algorithm: 'HS256' as const,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Brute Force Protection
|
||||
|
||||
@ -5,7 +5,6 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { I18nService, I18nValidationExceptionFilter, I18nValidationPipe } from 'nestjs-i18n';
|
||||
import helmet from 'helmet';
|
||||
import compression from 'compression';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { AppModule } from './app.module';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { helmetConfig, corsConfig } from './infrastructure/security/security.config';
|
||||
@ -34,9 +33,6 @@ async function bootstrap() {
|
||||
// Compression for API responses
|
||||
app.use(compression());
|
||||
|
||||
// Parse cookies (httpOnly auth cookies)
|
||||
app.use(cookieParser());
|
||||
|
||||
// CORS with strict configuration
|
||||
app.enableCors(corsConfig);
|
||||
|
||||
|
||||
@ -1,211 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,141 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
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`);
|
||||
}
|
||||
@ -6,7 +6,6 @@ import { ArrowLeft, Calendar, User, Tag, Clock, Share2, BookOpen, Anchor } from
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||
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';
|
||||
|
||||
@ -108,9 +107,9 @@ export default function BlogPostContent({ slug }: { slug: string }) {
|
||||
if (notFound || !post) return <NotFoundView />;
|
||||
|
||||
const readingTime = estimateReadingTime(post.content);
|
||||
// Sanitize the admin-authored HTML before rendering (stored-XSS defense in depth)
|
||||
const processedContent = DOMPurify.sanitize(
|
||||
post.content.replace(/src="(\/api\/v1\/blog\/images\/[^"]+)"/g, `src="${API_BASE_URL}$1"`)
|
||||
const processedContent = post.content.replace(
|
||||
/src="(\/api\/v1\/blog\/images\/[^"]+)"/g,
|
||||
`src="${API_BASE_URL}$1"`
|
||||
);
|
||||
|
||||
const handleShare = () => {
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
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}`);
|
||||
}
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { getAllUsers, updateAdminUser, deleteAdminUser } from '@/lib/api/admin';
|
||||
import { createUser } from '@/lib/api/users';
|
||||
import { getAllOrganizations } from '@/lib/api/admin';
|
||||
@ -28,10 +27,8 @@ interface Organization {
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const t = useTranslations('dashboard.admin.users');
|
||||
const tCommon = useTranslations('common');
|
||||
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -352,23 +349,12 @@ export default function AdminUsersPage() {
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.password')}
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={e => setFormData({ ...formData, password: e.target.value })}
|
||||
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"
|
||||
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"
|
||||
/>
|
||||
<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 className="flex justify-end space-x-2 pt-4">
|
||||
<button
|
||||
@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
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 type { ReactNode } from 'react';
|
||||
import ExportButton from '@/components/ExportButton';
|
||||
@ -277,9 +278,10 @@ export default function UserDocumentsPage() {
|
||||
const formData = new FormData();
|
||||
addFiles.forEach(file => formData.append('documents', file));
|
||||
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${selectedBookingId}/documents`,
|
||||
{ method: 'POST', credentials: 'include', body: formData }
|
||||
{ method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@ -334,9 +336,10 @@ export default function UserDocumentsPage() {
|
||||
const formData = new FormData();
|
||||
formData.append('document', replaceFile);
|
||||
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToReplace.bookingId}/documents/${documentToReplace.id}`,
|
||||
{ method: 'PATCH', credentials: 'include', body: formData }
|
||||
{ method: 'PATCH', headers: { Authorization: `Bearer ${token}` }, body: formData }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@ -6,6 +6,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import NotificationDropdown from '@/components/NotificationDropdown';
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||
import AdminPanelDropdown from '@/components/admin/AdminPanelDropdown';
|
||||
import Image from 'next/image';
|
||||
import {
|
||||
BarChart3,
|
||||
@ -20,7 +21,6 @@ import {
|
||||
Key,
|
||||
Home,
|
||||
User,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { useSubscription } from '@/lib/context/subscription-context';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
@ -159,13 +159,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
|
||||
{user?.role === 'ADMIN' && (
|
||||
<div className="pt-4 mt-4 border-t">
|
||||
<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>
|
||||
<AdminPanelDropdown />
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
@ -7,20 +7,15 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { updateUser, changePassword } from '@/lib/api';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const t = useTranslations('dashboard.profile');
|
||||
const tCommon = useTranslations('common');
|
||||
const { user, refreshUser, loading } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState<'profile' | 'password'>('profile');
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const passwordSchema = z
|
||||
.object({
|
||||
@ -331,30 +326,13 @@ export default function ProfilePage() {
|
||||
>
|
||||
{t('passwordForm.current')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
{...passwordForm.register('currentPassword')}
|
||||
type={showCurrentPassword ? 'text' : 'password'}
|
||||
type="password"
|
||||
id="currentPassword"
|
||||
autoComplete="current-password"
|
||||
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"
|
||||
className="w-full px-4 py-2 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 && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{passwordForm.formState.errors.currentPassword.message}
|
||||
@ -369,24 +347,13 @@ export default function ProfilePage() {
|
||||
>
|
||||
{t('passwordForm.new')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
{...passwordForm.register('newPassword')}
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
type="password"
|
||||
id="newPassword"
|
||||
autoComplete="new-password"
|
||||
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"
|
||||
className="w-full px-4 py-2 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 && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{passwordForm.formState.errors.newPassword.message}
|
||||
@ -402,30 +369,13 @@ export default function ProfilePage() {
|
||||
>
|
||||
{t('passwordForm.confirm')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
{...passwordForm.register('confirmPassword')}
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
autoComplete="new-password"
|
||||
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"
|
||||
className="w-full px-4 py-2 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 && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{passwordForm.formState.errors.confirmPassword.message}
|
||||
|
||||
@ -68,14 +68,7 @@ export default function ForgotPasswordPage() {
|
||||
<p
|
||||
className="text-body text-neutral-600"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('successMessage', {
|
||||
// Escape the user-typed email before HTML interpolation
|
||||
email: email
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"'),
|
||||
}),
|
||||
__html: t('successMessage', { email }),
|
||||
}}
|
||||
/>
|
||||
<p className="text-body-sm text-neutral-500 mt-3">{t('successHint')}</p>
|
||||
|
||||
@ -5,7 +5,6 @@ import { Link } from '@/i18n/navigation';
|
||||
import Image from 'next/image';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { useAuth } from '@/lib/context/auth-context';
|
||||
|
||||
function AnimatedCounter({
|
||||
@ -98,11 +97,9 @@ function LoginPageContent() {
|
||||
const tLogin = useTranslations('auth.login');
|
||||
const tPanel = useTranslations('auth.sidePanel');
|
||||
const tFooter = useTranslations('auth.footerLinks');
|
||||
const tCommon = useTranslations('common');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
@ -265,13 +262,12 @@ function LoginPageContent() {
|
||||
>
|
||||
{tLogin('passwordLabel')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
className={`input w-full pr-12 ${
|
||||
className={`input w-full ${
|
||||
fieldErrors.password
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-red-500 bg-red-50'
|
||||
: ''
|
||||
@ -282,16 +278,6 @@ function LoginPageContent() {
|
||||
aria-invalid={!!fieldErrors.password}
|
||||
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 && (
|
||||
<p
|
||||
id="password-error"
|
||||
|
||||
@ -5,7 +5,6 @@ import { useSearchParams } from 'next/navigation';
|
||||
import { Link, useRouter } from '@/i18n/navigation';
|
||||
import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { register } from '@/lib/api';
|
||||
import { verifyInvitation, type InvitationResponse } from '@/lib/api/invitations';
|
||||
import type { OrganizationType } from '@/types/api';
|
||||
@ -58,7 +57,6 @@ function RegisterPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations('auth.register');
|
||||
const tFooter = useTranslations('auth.footerLinks');
|
||||
const tCommon = useTranslations('common');
|
||||
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
const [statsActive, setStatsActive] = useState(false);
|
||||
@ -83,8 +81,6 @@ function RegisterPageContent() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const [organizationName, setOrganizationName] = useState('');
|
||||
const [organizationType, setOrganizationType] = useState<OrganizationType>('FREIGHT_FORWARDER');
|
||||
@ -517,28 +513,17 @@ function RegisterPageContent() {
|
||||
<label htmlFor="password" className="label">
|
||||
{t('passwordLabel')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="input w-full pr-12"
|
||||
className="input w-full"
|
||||
placeholder={t('passwordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
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>
|
||||
</div>
|
||||
|
||||
@ -546,34 +531,17 @@ function RegisterPageContent() {
|
||||
<label htmlFor="confirmPassword" className="label">
|
||||
{t('confirmPasswordLabel')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
type="password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
className="input w-full pr-12"
|
||||
className="input w-full"
|
||||
placeholder={t('passwordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
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>
|
||||
|
||||
<button
|
||||
|
||||
@ -5,20 +5,16 @@ import { useSearchParams } from 'next/navigation';
|
||||
import { Link, useRouter } from '@/i18n/navigation';
|
||||
import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { resetPassword } from '@/lib/api/auth';
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const t = useTranslations('auth.resetPassword');
|
||||
const tFooter = useTranslations('auth.footerLinks');
|
||||
const tCommon = useTranslations('common');
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [token, setToken] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -163,28 +159,17 @@ function ResetPasswordContent() {
|
||||
<label htmlFor="password" className="label">
|
||||
{t('passwordLabel')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="input w-full pr-12"
|
||||
className="input w-full"
|
||||
placeholder="••••••••••••"
|
||||
autoComplete="new-password"
|
||||
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>
|
||||
</div>
|
||||
|
||||
@ -192,34 +177,17 @@ function ResetPasswordContent() {
|
||||
<label htmlFor="confirmPassword" className="label">
|
||||
{t('confirmPasswordLabel')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
type="password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
className="input w-full pr-12"
|
||||
className="input w-full"
|
||||
placeholder="••••••••••••"
|
||||
autoComplete="new-password"
|
||||
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>
|
||||
|
||||
<button
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Link, useRouter } from '@/i18n/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
function VerifyEmailContent() {
|
||||
export default function VerifyEmailPage() {
|
||||
const t = useTranslations('auth.verifyEmail');
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
@ -140,12 +140,3 @@ function VerifyEmailContent() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
// useSearchParams() requires a Suspense boundary for prerendering (Next 14.2+)
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<VerifyEmailContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Health check endpoint used by the Docker HEALTHCHECK.
|
||||
*/
|
||||
export function GET() {
|
||||
return Response.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
}
|
||||
@ -25,29 +25,7 @@
|
||||
"discover": "Discover",
|
||||
"tryNow": "Try now",
|
||||
"continue": "Continue",
|
||||
"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"
|
||||
}
|
||||
"submit": "Submit"
|
||||
},
|
||||
"language": {
|
||||
"label": "Language",
|
||||
@ -325,8 +303,7 @@
|
||||
"wiki": "Maritime Wiki",
|
||||
"organization": "Organization",
|
||||
"apiKeys": "API Keys",
|
||||
"users": "Users",
|
||||
"admin": "Administration"
|
||||
"users": "Users"
|
||||
},
|
||||
"topbar": {
|
||||
"defaultTitle": "Dashboard"
|
||||
@ -1746,10 +1723,7 @@
|
||||
],
|
||||
"extensionsTitle": "Coverage 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",
|
||||
"description": "Covers losses due to strikes, riots, civil commotion"
|
||||
@ -1875,42 +1849,15 @@
|
||||
"colItem": "Item",
|
||||
"colAmount": "Amount",
|
||||
"exampleItems": [
|
||||
{
|
||||
"item": "Base ocean freight",
|
||||
"amount": "1,200 USD"
|
||||
},
|
||||
{
|
||||
"item": "BAF (Bunker)",
|
||||
"amount": "350 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"
|
||||
}
|
||||
{ "item": "Base ocean freight", "amount": "1,200 USD" },
|
||||
{ "item": "BAF (Bunker)", "amount": "350 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": {
|
||||
@ -1982,10 +1929,7 @@
|
||||
],
|
||||
"specialEquipmentTitle": "Special Equipment",
|
||||
"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",
|
||||
"description": "For dry bulk (grains, minerals) — top hatch"
|
||||
@ -2007,26 +1951,11 @@
|
||||
"condition": "Standard general cargo",
|
||||
"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": "Over-length/weight cargo",
|
||||
"recommendation": "Flat Rack or Platform"
|
||||
},
|
||||
{
|
||||
"condition": "Bulk liquids",
|
||||
"recommendation": "ISO Tank"
|
||||
},
|
||||
{
|
||||
"condition": "Volume < 15 m³",
|
||||
"recommendation": "Consider LCL"
|
||||
}
|
||||
{ "condition": "Temperature-sensitive goods", "recommendation": "Reefer 20' or 40'" },
|
||||
{ "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": {
|
||||
@ -2214,10 +2143,7 @@
|
||||
"type": "VAT",
|
||||
"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": {
|
||||
@ -2276,16 +2202,8 @@
|
||||
"description": "Toxic and 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",
|
||||
"name": "Miscellaneous",
|
||||
@ -2321,14 +2239,8 @@
|
||||
"group": "Group I (X)",
|
||||
"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",
|
||||
"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.",
|
||||
@ -2352,11 +2264,7 @@
|
||||
"lcl": "< 15 m³ or < 10 tonnes",
|
||||
"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",
|
||||
"lcl": "Moderate (shared with others)",
|
||||
@ -2465,10 +2373,7 @@
|
||||
"role": "Applicant (Importer)",
|
||||
"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)",
|
||||
"description": "The seller who benefits from the L/C"
|
||||
@ -2492,10 +2397,7 @@
|
||||
"name": "Commercial Invoice",
|
||||
"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",
|
||||
"description": "Required if CIF or CIP — amounts and coverage per L/C"
|
||||
@ -2535,10 +2437,7 @@
|
||||
"label": "Presentation deadline",
|
||||
"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",
|
||||
"costsItems": [
|
||||
@ -2550,14 +2449,8 @@
|
||||
"label": "Confirmation commission",
|
||||
"description": "0.2–0.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": {
|
||||
@ -2636,66 +2529,16 @@
|
||||
"colCountry": "Country",
|
||||
"colTeu": "TEU / year",
|
||||
"ports": [
|
||||
{
|
||||
"rank": 1,
|
||||
"port": "Shanghai",
|
||||
"country": "China",
|
||||
"teu": "47M"
|
||||
},
|
||||
{
|
||||
"rank": 2,
|
||||
"port": "Singapore",
|
||||
"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"
|
||||
}
|
||||
{ "rank": 1, "port": "Shanghai", "country": "China", "teu": "47M" },
|
||||
{ "rank": 2, "port": "Singapore", "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",
|
||||
"hubTitle": "Hub Port",
|
||||
@ -2733,11 +2576,7 @@
|
||||
"description": "Empty weight of the container (shown on the door)",
|
||||
"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",
|
||||
"description": "Pallets, cartons, plastic film...",
|
||||
@ -2803,22 +2642,10 @@
|
||||
"consequenceValue": "Re-weighing at shipper's expense, possible delay",
|
||||
"sanctionsTitle": "Sanctions by Region",
|
||||
"sanctions": [
|
||||
{
|
||||
"region": "France",
|
||||
"sanction": "Fine up to €7,500 and refusal to load"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
{ "region": "France", "sanction": "Fine up to €7,500 and refusal to load" },
|
||||
{ "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",
|
||||
"tips": [
|
||||
@ -2913,41 +2740,13 @@
|
||||
"colTime": "Transit Time",
|
||||
"colVia": "Via",
|
||||
"transitTimes": [
|
||||
{
|
||||
"route": "Shanghai → Rotterdam",
|
||||
"time": "28–32 days",
|
||||
"via": "Suez"
|
||||
},
|
||||
{
|
||||
"route": "Shanghai → Le Havre",
|
||||
"time": "30–35 days",
|
||||
"via": "Suez"
|
||||
},
|
||||
{
|
||||
"route": "Shanghai → Los Angeles",
|
||||
"time": "12–15 days",
|
||||
"via": "Direct Pacific"
|
||||
},
|
||||
{
|
||||
"route": "Shanghai → New York",
|
||||
"time": "35–40 days",
|
||||
"via": "Suez or Panama"
|
||||
},
|
||||
{
|
||||
"route": "Rotterdam → New York",
|
||||
"time": "10–14 days",
|
||||
"via": "Direct Atlantic"
|
||||
},
|
||||
{
|
||||
"route": "Mumbai → Rotterdam",
|
||||
"time": "18–22 days",
|
||||
"via": "Suez"
|
||||
},
|
||||
{
|
||||
"route": "Santos → Rotterdam",
|
||||
"time": "18–22 days",
|
||||
"via": "Direct Atlantic"
|
||||
}
|
||||
{ "route": "Shanghai → Rotterdam", "time": "28–32 days", "via": "Suez" },
|
||||
{ "route": "Shanghai → Le Havre", "time": "30–35 days", "via": "Suez" },
|
||||
{ "route": "Shanghai → Los Angeles", "time": "12–15 days", "via": "Direct Pacific" },
|
||||
{ "route": "Shanghai → New York", "time": "35–40 days", "via": "Suez or Panama" },
|
||||
{ "route": "Rotterdam → New York", "time": "10–14 days", "via": "Direct Atlantic" },
|
||||
{ "route": "Mumbai → Rotterdam", "time": "18–22 days", "via": "Suez" },
|
||||
{ "route": "Santos → Rotterdam", "time": "18–22 days", "via": "Direct Atlantic" }
|
||||
],
|
||||
"transitNote": "Note: These times are indicative and vary depending on rotations, transshipments and conditions.",
|
||||
"freeTimeTitle": "Free Time (Free Days)",
|
||||
|
||||
@ -25,29 +25,7 @@
|
||||
"discover": "Découvrir",
|
||||
"tryNow": "Essayer maintenant",
|
||||
"continue": "Continuer",
|
||||
"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"
|
||||
}
|
||||
"submit": "Envoyer"
|
||||
},
|
||||
"language": {
|
||||
"label": "Langue",
|
||||
@ -325,8 +303,7 @@
|
||||
"wiki": "Wiki Maritime",
|
||||
"organization": "Organisation",
|
||||
"apiKeys": "Clés API",
|
||||
"users": "Utilisateurs",
|
||||
"admin": "Administration"
|
||||
"users": "Utilisateurs"
|
||||
},
|
||||
"topbar": {
|
||||
"defaultTitle": "Tableau de bord"
|
||||
@ -1881,42 +1858,15 @@
|
||||
"colItem": "Poste",
|
||||
"colAmount": "Montant",
|
||||
"exampleItems": [
|
||||
{
|
||||
"item": "Fret maritime de base",
|
||||
"amount": "1 200 USD"
|
||||
},
|
||||
{
|
||||
"item": "BAF (Bunker)",
|
||||
"amount": "350 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"
|
||||
}
|
||||
{ "item": "Fret maritime de base", "amount": "1 200 USD" },
|
||||
{ "item": "BAF (Bunker)", "amount": "350 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": {
|
||||
@ -2025,14 +1975,8 @@
|
||||
"condition": "Marchandises hors-gabarit / très lourdes",
|
||||
"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": {
|
||||
@ -2282,16 +2226,8 @@
|
||||
"description": "Matières toxiques et 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",
|
||||
"name": "Divers",
|
||||
@ -2327,14 +2263,8 @@
|
||||
"group": "Groupe I (X)",
|
||||
"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",
|
||||
"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.",
|
||||
@ -2498,10 +2428,7 @@
|
||||
"name": "Facture Commerciale",
|
||||
"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",
|
||||
"description": "Requis si CIF ou CIP — montants et couverture selon L/C"
|
||||
@ -2556,10 +2483,7 @@
|
||||
"label": "Commission de confirmation",
|
||||
"description": "0,2–0,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",
|
||||
"description": "Frais fixes en cas de réserve sur les documents"
|
||||
@ -2642,66 +2566,16 @@
|
||||
"colCountry": "Pays",
|
||||
"colTeu": "TEU / an",
|
||||
"ports": [
|
||||
{
|
||||
"rank": 1,
|
||||
"port": "Shanghai",
|
||||
"country": "Chine",
|
||||
"teu": "47M"
|
||||
},
|
||||
{
|
||||
"rank": 2,
|
||||
"port": "Singapour",
|
||||
"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"
|
||||
}
|
||||
{ "rank": 1, "port": "Shanghai", "country": "Chine", "teu": "47M" },
|
||||
{ "rank": 2, "port": "Singapour", "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",
|
||||
"hubTitle": "Port Hub",
|
||||
@ -2809,22 +2683,10 @@
|
||||
"consequenceValue": "Nouvelle pesée à la charge de l'expéditeur, retard possible",
|
||||
"sanctionsTitle": "Sanctions par Région",
|
||||
"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": "Chine",
|
||||
"sanction": "Refus d'embarquement, pénalités portuaires"
|
||||
},
|
||||
{
|
||||
"region": "Union Européenne",
|
||||
"sanction": "Application variable selon pays membre"
|
||||
}
|
||||
{ "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": "Chine", "sanction": "Refus d'embarquement, pénalités portuaires" },
|
||||
{ "region": "Union Européenne", "sanction": "Application variable selon pays membre" }
|
||||
],
|
||||
"tipsTitle": "Bonnes Pratiques",
|
||||
"tips": [
|
||||
@ -2919,41 +2781,13 @@
|
||||
"colTime": "Transit Time",
|
||||
"colVia": "Via",
|
||||
"transitTimes": [
|
||||
{
|
||||
"route": "Shanghai → Rotterdam",
|
||||
"time": "28–32 jours",
|
||||
"via": "Suez"
|
||||
},
|
||||
{
|
||||
"route": "Shanghai → Le Havre",
|
||||
"time": "30–35 jours",
|
||||
"via": "Suez"
|
||||
},
|
||||
{
|
||||
"route": "Shanghai → Los Angeles",
|
||||
"time": "12–15 jours",
|
||||
"via": "Pacifique direct"
|
||||
},
|
||||
{
|
||||
"route": "Shanghai → New York",
|
||||
"time": "35–40 jours",
|
||||
"via": "Suez ou Panama"
|
||||
},
|
||||
{
|
||||
"route": "Rotterdam → New York",
|
||||
"time": "10–14 jours",
|
||||
"via": "Atlantique direct"
|
||||
},
|
||||
{
|
||||
"route": "Mumbai → Rotterdam",
|
||||
"time": "18–22 jours",
|
||||
"via": "Suez"
|
||||
},
|
||||
{
|
||||
"route": "Santos → Rotterdam",
|
||||
"time": "18–22 jours",
|
||||
"via": "Atlantique direct"
|
||||
}
|
||||
{ "route": "Shanghai → Rotterdam", "time": "28–32 jours", "via": "Suez" },
|
||||
{ "route": "Shanghai → Le Havre", "time": "30–35 jours", "via": "Suez" },
|
||||
{ "route": "Shanghai → Los Angeles", "time": "12–15 jours", "via": "Pacifique direct" },
|
||||
{ "route": "Shanghai → New York", "time": "35–40 jours", "via": "Suez ou Panama" },
|
||||
{ "route": "Rotterdam → New York", "time": "10–14 jours", "via": "Atlantique direct" },
|
||||
{ "route": "Mumbai → Rotterdam", "time": "18–22 jours", "via": "Suez" },
|
||||
{ "route": "Santos → Rotterdam", "time": "18–22 jours", "via": "Atlantique direct" }
|
||||
],
|
||||
"transitNote": "Note : Ces temps sont indicatifs et varient selon les rotations, transbordements et conditions.",
|
||||
"freeTimeTitle": "Free Time (Jours Gratuits)",
|
||||
|
||||
@ -29,7 +29,6 @@ const prefixPublicPaths = [
|
||||
'/press',
|
||||
'/contact',
|
||||
'/carrier',
|
||||
'/admin/login',
|
||||
'/pricing',
|
||||
'/docs',
|
||||
'/privacy',
|
||||
@ -72,11 +71,7 @@ export default function middleware(request: NextRequest) {
|
||||
const token = request.cookies.get('accessToken')?.value;
|
||||
|
||||
if (!isPublicPath && !token) {
|
||||
// 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);
|
||||
const loginUrl = new URL(`/${resolvedLocale}/login`, request.url);
|
||||
loginUrl.searchParams.set('redirect', pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
829
apps/frontend/package-lock.json
generated
829
apps/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -42,10 +42,9 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"framer-motion": "^12.23.24",
|
||||
"isomorphic-dompurify": "^3.16.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.294.0",
|
||||
"next": "^14.2.35",
|
||||
"next": "14.0.4",
|
||||
"next-intl": "^4.9.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
117
apps/frontend/src/components/admin/AdminPanelDropdown.tsx
Normal file
117
apps/frontend/src/components/admin/AdminPanelDropdown.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -3,7 +3,6 @@
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { Carrier, CarrierStatus, CreateCarrierInput, UpdateCarrierInput } from '@/types/carrier';
|
||||
|
||||
interface CarrierFormProps {
|
||||
@ -26,7 +25,6 @@ export const CarrierForm: React.FC<CarrierFormProps> = ({ carrier, onSubmit, onC
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@ -131,24 +129,13 @@ export const CarrierForm: React.FC<CarrierFormProps> = ({ carrier, onSubmit, onC
|
||||
{/* API Key */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">API Key</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
type="password"
|
||||
value={formData.apiKey}
|
||||
onChange={e => setFormData({ ...formData, apiKey: e.target.value })}
|
||||
placeholder="Enter API key"
|
||||
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"
|
||||
className="w-full px-3 py-2 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>
|
||||
|
||||
{/* Rate Limit */}
|
||||
|
||||
@ -41,7 +41,9 @@ export function useBookings(initialFilters?: BookingFilters) {
|
||||
queryParams.append('pageSize', String(filters.pageSize || 20));
|
||||
|
||||
const response = await fetch(`/api/v1/bookings/advanced/search?${queryParams.toString()}`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@ -100,9 +102,9 @@ export function useBookings(initialFilters?: BookingFilters) {
|
||||
try {
|
||||
const response = await fetch('/api/v1/bookings/export', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
format: options.format,
|
||||
|
||||
@ -108,7 +108,11 @@ export async function exportAuditLogs(params?: {
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/audit/export?${queryParams.toString()}`,
|
||||
{
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Authorization: `Bearer ${
|
||||
typeof window !== 'undefined' ? localStorage.getItem('access_token') : ''
|
||||
}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
* Endpoints for user authentication and session management
|
||||
*/
|
||||
|
||||
import { get, post, clearAuthTokens } from './client';
|
||||
import { get, post, setAuthTokens, clearAuthTokens } from './client';
|
||||
import type {
|
||||
RegisterRequest,
|
||||
LoginRequest,
|
||||
@ -19,8 +19,12 @@ import type {
|
||||
* POST /api/v1/auth/register
|
||||
*/
|
||||
export async function register(data: RegisterRequest): Promise<AuthResponse> {
|
||||
// Tokens are delivered as httpOnly cookies by the backend
|
||||
return post<AuthResponse>('/api/v1/auth/register', data, false);
|
||||
const response = await post<AuthResponse>('/api/v1/auth/register', data, false);
|
||||
|
||||
// Store tokens
|
||||
setAuthTokens(response.accessToken, response.refreshToken);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -28,18 +32,21 @@ export async function register(data: RegisterRequest): Promise<AuthResponse> {
|
||||
* POST /api/v1/auth/login
|
||||
*/
|
||||
export async function login(data: LoginRequest & { rememberMe?: boolean }): Promise<AuthResponse> {
|
||||
// Tokens are delivered as httpOnly cookies by the backend;
|
||||
// rememberMe drives cookie persistence server-side
|
||||
return post<AuthResponse>('/api/v1/auth/login', data, false);
|
||||
const { rememberMe, ...loginData } = data;
|
||||
const response = await post<AuthResponse>('/api/v1/auth/login', loginData, false);
|
||||
|
||||
// Store tokens — localStorage if rememberMe, sessionStorage otherwise
|
||||
setAuthTokens(response.accessToken, response.refreshToken, rememberMe ?? false);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
* POST /api/v1/auth/refresh
|
||||
*/
|
||||
export async function refreshToken(data?: RefreshTokenRequest): Promise<{ success: boolean }> {
|
||||
// The backend reads the httpOnly refresh cookie and rotates the cookies
|
||||
return post<{ success: boolean }>('/api/v1/auth/refresh', data ?? {}, false);
|
||||
export async function refreshToken(data: RefreshTokenRequest): Promise<{ accessToken: string }> {
|
||||
return post<{ accessToken: string }>('/api/v1/auth/refresh', data, false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -181,7 +181,11 @@ export async function exportBookings(params: {
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/bookings/export?${queryParams.toString()}`,
|
||||
{
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Authorization: `Bearer ${
|
||||
typeof window !== 'undefined' ? localStorage.getItem('access_token') : ''
|
||||
}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@ -1,37 +1,47 @@
|
||||
/**
|
||||
* API Client Base
|
||||
*
|
||||
* 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.
|
||||
* Core HTTP client with authentication and error handling
|
||||
*/
|
||||
|
||||
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
|
||||
let isRefreshing = false;
|
||||
let refreshSubscribers: Array<() => void> = [];
|
||||
let refreshSubscribers: Array<(token: string) => void> = [];
|
||||
|
||||
/**
|
||||
* Whether an authenticated session exists (based on the readable flag cookie
|
||||
* set by the backend alongside the httpOnly token cookies).
|
||||
* Get authentication token — checks localStorage first (remember me), then sessionStorage
|
||||
*/
|
||||
export function hasSession(): boolean {
|
||||
if (typeof document === 'undefined') return false;
|
||||
return document.cookie.split('; ').some(cookie => cookie.startsWith(`${SESSION_FLAG_COOKIE}=`));
|
||||
export function getAuthToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear client-side auth state.
|
||||
* Token cookies are httpOnly and are cleared by the backend on logout;
|
||||
* this removes the user cache and any tokens left over from the legacy
|
||||
* localStorage-based auth.
|
||||
* Get refresh token — checks localStorage first (remember me), then sessionStorage
|
||||
*/
|
||||
export function getRefreshToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
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 {
|
||||
if (typeof window === 'undefined') return;
|
||||
@ -41,39 +51,41 @@ export function clearAuthTokens(): void {
|
||||
sessionStorage.removeItem('access_token');
|
||||
sessionStorage.removeItem('refresh_token');
|
||||
sessionStorage.removeItem('user');
|
||||
// Expire the legacy middleware cookie and the session flag (best effort —
|
||||
// the backend clears the authoritative httpOnly cookies)
|
||||
// Expire the middleware cookie
|
||||
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 the session is refreshed
|
||||
* Add subscriber to be notified when token is refreshed
|
||||
*/
|
||||
function subscribeTokenRefresh(callback: () => void): void {
|
||||
function subscribeTokenRefresh(callback: (token: string) => void): void {
|
||||
refreshSubscribers.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all subscribers that the session has been refreshed
|
||||
* Notify all subscribers that token has been refreshed
|
||||
*/
|
||||
function onTokenRefreshed(): void {
|
||||
refreshSubscribers.forEach(callback => callback());
|
||||
function onTokenRefreshed(token: string): void {
|
||||
refreshSubscribers.forEach(callback => callback(token));
|
||||
refreshSubscribers = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the session — the backend reads the httpOnly refresh cookie and
|
||||
* sets new token cookies on success.
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
async function refreshSession(): Promise<void> {
|
||||
async function refreshAccessToken(): Promise<string> {
|
||||
const refreshToken = getRefreshToken();
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@ -83,24 +95,54 @@ async function refreshSession(): Promise<void> {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Failed to refresh session');
|
||||
throw new Error('Failed to refresh token');
|
||||
}
|
||||
|
||||
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 (auth is carried by httpOnly cookies, not headers)
|
||||
* Create headers with authentication
|
||||
*/
|
||||
export function createHeaders(_includeAuth = true): HeadersInit {
|
||||
return {
|
||||
export function createHeaders(includeAuth = true): HeadersInit {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (includeAuth) {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create headers for multipart form data
|
||||
*/
|
||||
export function createMultipartHeaders(_includeAuth = true): HeadersInit {
|
||||
return {};
|
||||
export function createMultipartHeaders(includeAuth = true): HeadersInit {
|
||||
const headers: HeadersInit = {};
|
||||
|
||||
if (includeAuth) {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -118,7 +160,7 @@ export class ApiError extends Error {
|
||||
}
|
||||
|
||||
/**
|
||||
* Make API request with automatic session refresh on 401
|
||||
* Make API request with automatic token refresh on 401
|
||||
*/
|
||||
export async function apiRequest<T>(
|
||||
endpoint: string,
|
||||
@ -129,7 +171,6 @@ export async function apiRequest<T>(
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...options.headers,
|
||||
},
|
||||
@ -143,8 +184,10 @@ export async function apiRequest<T>(
|
||||
endpoint.includes('/auth/refresh');
|
||||
|
||||
if (response.status === 401 && !isRetry && !isAuthEndpoint) {
|
||||
if (!hasSession()) {
|
||||
// No session, redirect to login
|
||||
// Check if we have a refresh token
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
// No refresh token, redirect to login
|
||||
clearAuthTokens();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
@ -152,21 +195,46 @@ export async function apiRequest<T>(
|
||||
throw new ApiError('Session expired', 401);
|
||||
}
|
||||
|
||||
// Try to refresh the session (cookies are updated server-side)
|
||||
// Try to refresh the token
|
||||
try {
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true;
|
||||
await refreshSession();
|
||||
const newAccessToken = await refreshAccessToken();
|
||||
isRefreshing = false;
|
||||
onTokenRefreshed();
|
||||
onTokenRefreshed(newAccessToken);
|
||||
|
||||
return apiRequest<T>(endpoint, options, true);
|
||||
// Retry the original request with new token
|
||||
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 {
|
||||
// Already refreshing, wait for it to complete
|
||||
// Already refreshing, wait for the new token
|
||||
return new Promise((resolve, reject) => {
|
||||
subscribeTokenRefresh(async () => {
|
||||
subscribeTokenRefresh(async (newAccessToken: string) => {
|
||||
const newHeaders = { ...options.headers };
|
||||
if (newHeaders && typeof newHeaders === 'object' && 'Authorization' in newHeaders) {
|
||||
(newHeaders as any)['Authorization'] = `Bearer ${newAccessToken}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await apiRequest<T>(endpoint, options, true);
|
||||
const result = await apiRequest<T>(
|
||||
endpoint,
|
||||
{
|
||||
...options,
|
||||
headers: newHeaders,
|
||||
},
|
||||
true
|
||||
);
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
@ -252,20 +320,23 @@ export async function upload<T>(
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: createMultipartHeaders(includeAuth),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
// Handle 401 Unauthorized for file uploads
|
||||
if (response.status === 401 && hasSession()) {
|
||||
if (response.status === 401) {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (refreshToken) {
|
||||
try {
|
||||
await refreshSession();
|
||||
// Retry upload with refreshed cookies
|
||||
const newAccessToken = await refreshAccessToken();
|
||||
// Retry upload with new token
|
||||
const retryResponse = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: createMultipartHeaders(includeAuth),
|
||||
headers: {
|
||||
...createMultipartHeaders(includeAuth),
|
||||
Authorization: `Bearer ${newAccessToken}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
@ -287,6 +358,7 @@ export async function upload<T>(
|
||||
throw refreshError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
@ -312,19 +384,22 @@ export async function download(
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: createHeaders(includeAuth),
|
||||
});
|
||||
|
||||
// Handle 401 Unauthorized for downloads
|
||||
if (response.status === 401 && hasSession()) {
|
||||
if (response.status === 401) {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (refreshToken) {
|
||||
try {
|
||||
await refreshSession();
|
||||
// Retry download with refreshed cookies
|
||||
const newAccessToken = await refreshAccessToken();
|
||||
// Retry download with new token
|
||||
const retryResponse = await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: createHeaders(includeAuth),
|
||||
headers: {
|
||||
...createHeaders(includeAuth),
|
||||
Authorization: `Bearer ${newAccessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!retryResponse.ok) {
|
||||
@ -349,6 +424,7 @@ export async function download(
|
||||
throw refreshError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(`Download failed: ${response.statusText}`, response.status);
|
||||
|
||||
@ -14,12 +14,27 @@ import {
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
/**
|
||||
* Create headers — auth is carried by httpOnly cookies (credentials: 'include')
|
||||
* Get authentication token from localStorage
|
||||
*/
|
||||
function getAuthToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('access_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create headers with authentication
|
||||
*/
|
||||
function createHeaders(): HeadersInit {
|
||||
return {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -30,7 +45,6 @@ export async function searchCsvRates(
|
||||
): Promise<CsvRateSearchResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/rates/search-csv`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: createHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
@ -49,7 +63,6 @@ export async function searchCsvRates(
|
||||
export async function getAvailableCompanies(): Promise<AvailableCompanies> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/rates/companies`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: createHeaders(),
|
||||
});
|
||||
|
||||
@ -66,7 +79,6 @@ export async function getAvailableCompanies(): Promise<AvailableCompanies> {
|
||||
export async function getFilterOptions(): Promise<FilterOptions> {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/rates/filters/options`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: createHeaders(),
|
||||
});
|
||||
|
||||
|
||||
@ -53,7 +53,11 @@ export interface GdprDataExportResponse {
|
||||
export async function requestDataExport(): Promise<Blob> {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Authorization: `Bearer ${
|
||||
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
|
||||
}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@ -70,7 +74,11 @@ export async function requestDataExport(): Promise<Blob> {
|
||||
export async function requestDataExportCSV(): Promise<Blob> {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export/csv`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Authorization: `Bearer ${
|
||||
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
|
||||
}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@ -88,9 +96,11 @@ export async function requestDataExportCSV(): Promise<Blob> {
|
||||
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`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${
|
||||
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
|
||||
}`,
|
||||
},
|
||||
body: JSON.stringify({ confirmEmail, reason }),
|
||||
});
|
||||
|
||||
@ -10,7 +10,8 @@
|
||||
|
||||
// Base client utilities
|
||||
export {
|
||||
hasSession,
|
||||
getAuthToken,
|
||||
setAuthTokens,
|
||||
clearAuthTokens,
|
||||
createHeaders,
|
||||
apiRequest,
|
||||
|
||||
@ -34,6 +34,12 @@ export interface PortSearchResponse {
|
||||
* Search ports by query (autocomplete)
|
||||
*/
|
||||
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({
|
||||
query: params.query,
|
||||
});
|
||||
@ -48,9 +54,9 @@ export async function searchPorts(params: PortSearchParams): Promise<PortSearchR
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/ports/search?${queryParams.toString()}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
logout as apiLogout,
|
||||
getCurrentUser,
|
||||
} from '../api/auth';
|
||||
import { hasSession, clearAuthTokens } from '../api/client';
|
||||
import { getAuthToken } from '../api/client';
|
||||
import type { UserPayload } from '@/types/api';
|
||||
|
||||
interface AuthContextType {
|
||||
@ -45,9 +45,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
// Helper function to check if user is authenticated (session flag cookie)
|
||||
// Helper function to check if user is authenticated
|
||||
const isAuthenticated = () => {
|
||||
return hasSession();
|
||||
return !!getAuthToken();
|
||||
};
|
||||
|
||||
// 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(() => {
|
||||
@ -55,20 +62,31 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
if (isAuthenticated()) {
|
||||
// Try to fetch current user from API (will auto-refresh the session if expired)
|
||||
// Try to fetch current user from API (will auto-refresh token if expired)
|
||||
try {
|
||||
const currentUser = await getCurrentUser();
|
||||
setUser(currentUser);
|
||||
// Update stored user
|
||||
localStorage.setItem('user', JSON.stringify(currentUser));
|
||||
} catch (apiError) {
|
||||
console.error('Failed to fetch user from API:', apiError);
|
||||
// If API fails after session refresh attempt, clear everything
|
||||
clearAuthTokens();
|
||||
// If API fails after token refresh attempt, clear everything
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed, clearing session:', error);
|
||||
clearAuthTokens();
|
||||
console.error('Auth check failed, clearing tokens:', error);
|
||||
// Token invalid or no user data, clear storage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@ -104,9 +122,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
) => {
|
||||
try {
|
||||
await apiLogin({ email, password, rememberMe });
|
||||
// Fetch complete user profile after login (session lives in httpOnly cookies)
|
||||
// Fetch complete user profile after login
|
||||
const currentUser = await getCurrentUser();
|
||||
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);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
@ -121,10 +144,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
organizationId: string;
|
||||
}) => {
|
||||
try {
|
||||
await apiRegister(data);
|
||||
const response = await apiRegister(data);
|
||||
// Fetch complete user profile after registration
|
||||
const currentUser = await getCurrentUser();
|
||||
setUser(currentUser);
|
||||
// Store user in localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('user', JSON.stringify(currentUser));
|
||||
}
|
||||
router.push('/dashboard');
|
||||
} catch (error) {
|
||||
throw error;
|
||||
@ -136,6 +163,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
await apiLogout();
|
||||
} finally {
|
||||
setUser(null);
|
||||
// Clear user from localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
@ -144,6 +175,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
try {
|
||||
const currentUser = await getCurrentUser();
|
||||
setUser(currentUser);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('user', JSON.stringify(currentUser));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh user:', error);
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
updateConsentPreferences,
|
||||
type CookiePreferences,
|
||||
} from '../api/gdpr';
|
||||
import { hasSession } from '../api/client';
|
||||
import { getAuthToken } from '../api/client';
|
||||
|
||||
const STORAGE_KEY = 'cookieConsent';
|
||||
const STORAGE_DATE_KEY = 'cookieConsentDate';
|
||||
@ -49,7 +49,7 @@ export function CookieProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Check if user is authenticated
|
||||
const isAuthenticated = useCallback(() => {
|
||||
return hasSession();
|
||||
return !!getAuthToken();
|
||||
}, []);
|
||||
|
||||
// Load preferences from localStorage
|
||||
|
||||
@ -27,6 +27,7 @@ export interface RegisterRequest {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
invitationToken?: string; // For invited users (token-based)
|
||||
organizationId?: string; // For invited users (ID-based)
|
||||
organization?: RegisterOrganizationData; // For new users
|
||||
}
|
||||
|
||||
|
||||
@ -103,8 +103,8 @@ services:
|
||||
REDIS_PASSWORD: xpeditis_redis_password
|
||||
REDIS_DB: 0
|
||||
|
||||
# JWT (must be at least 32 characters — enforced at startup)
|
||||
JWT_SECRET: dev-secret-jwt-key-for-docker-local-only-0123456789
|
||||
# JWT
|
||||
JWT_SECRET: dev-secret-jwt-key-for-docker
|
||||
JWT_ACCESS_EXPIRATION: 15m
|
||||
JWT_REFRESH_EXPIRATION: 7d
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user