diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index 5c187701..7388a82d 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -2,10 +2,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; -import { MailerService } from '@nestjs-modules/mailer'; import * as bcrypt from 'bcrypt'; import { AuthService } from './auth.service'; import { UsersService } from '../users/users.service'; +import { MailService } from '../mailer/mail.service'; import { User } from '../users/entities/user.entity'; import { UserRole } from '../common/enums/role.enum'; @@ -32,6 +32,9 @@ function makeUser(overrides: Partial = {}): User { resetPasswordExpiry: null, createdAt: new Date(), updatedAt: new Date(), + isTwoFactorEnabled: false, + twoFactorSecret: undefined as any, + recoveryCodes: [], ...overrides, }; } @@ -42,7 +45,7 @@ describe('AuthService', () => { let service: AuthService; let usersService: jest.Mocked; let jwtService: jest.Mocked; - let mailerService: jest.Mocked; + let mailService: jest.Mocked; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -88,8 +91,8 @@ describe('AuthService', () => { }, }, { - provide: MailerService, - useValue: { sendMail: jest.fn() }, + provide: MailService, + useValue: { send: jest.fn() }, }, ], }).compile(); @@ -97,7 +100,7 @@ describe('AuthService', () => { service = module.get(AuthService); usersService = module.get(UsersService); jwtService = module.get(JwtService); - mailerService = module.get(MailerService); + mailService = module.get(MailService); }); // ── register ─────────────────────────────────────────────────────────────── @@ -108,7 +111,7 @@ describe('AuthService', () => { usersService.create.mockResolvedValue(user); usersService.updateVerificationToken.mockResolvedValue(undefined); usersService.updateRefreshToken.mockResolvedValue(undefined); - mailerService.sendMail.mockResolvedValue(undefined as never); + mailService.send.mockResolvedValue(undefined as never); jwtService.signAsync .mockResolvedValueOnce('access-token') .mockResolvedValueOnce('refresh-token'); @@ -123,7 +126,7 @@ describe('AuthService', () => { expect(usersService.create).toHaveBeenCalled(); expect(usersService.updateVerificationToken).toHaveBeenCalled(); - expect(mailerService.sendMail).toHaveBeenCalled(); + expect(mailService.send).toHaveBeenCalled(); expect(result.accessToken).toBe('access-token'); expect(result.refreshToken).toBe('refresh-token'); expect(result.user).not.toHaveProperty('passwordHash'); @@ -135,7 +138,7 @@ describe('AuthService', () => { usersService.create.mockResolvedValue(user); usersService.updateVerificationToken.mockResolvedValue(undefined); usersService.updateRefreshToken.mockResolvedValue(undefined); - mailerService.sendMail.mockRejectedValue(new Error('SMTP error')); + mailService.send.mockRejectedValue(new Error('SMTP error')); jwtService.signAsync .mockResolvedValueOnce('access-token') .mockResolvedValueOnce('refresh-token'); @@ -282,12 +285,12 @@ describe('AuthService', () => { const user = makeUser(); usersService.findByEmail.mockResolvedValue(user); usersService.setResetToken.mockResolvedValue(undefined); - mailerService.sendMail.mockResolvedValue(undefined as never); + mailService.send.mockResolvedValue(undefined as never); const result = await service.forgotPassword('test@example.com'); expect(usersService.setResetToken).toHaveBeenCalled(); - expect(mailerService.sendMail).toHaveBeenCalled(); + expect(mailService.send).toHaveBeenCalled(); expect(result.message).toBeDefined(); }); @@ -304,7 +307,7 @@ describe('AuthService', () => { const user = makeUser(); usersService.findByEmail.mockResolvedValue(user); usersService.setResetToken.mockResolvedValue(undefined); - mailerService.sendMail.mockRejectedValue(new Error('SMTP error')); + mailService.send.mockRejectedValue(new Error('SMTP error')); const result = await service.forgotPassword('test@example.com'); diff --git a/backend/src/auth/two-factor.service.ts b/backend/src/auth/two-factor.service.ts index d4329a58..a636de77 100644 --- a/backend/src/auth/two-factor.service.ts +++ b/backend/src/auth/two-factor.service.ts @@ -5,14 +5,17 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, IsNull } from 'typeorm'; +import { TOTP, generateURI } from 'otplib'; // import { authenticator } from 'otplib'; import { authenticator } from '@otplib/preset-v11'; import * as qrcode from 'qrcode'; import * as bcrypt from 'bcrypt'; -import { Redis } from 'ioredis'; // Assuming BE-02 Redis instance wrapper setup +import { Redis } from 'ioredis'; import { User } from '../users/entities/user.entity'; import { TwoFactorRecovery } from '../users/entities/two-factor-recovery.entity'; +const authenticator = new TOTP(); + @Injectable() export class TwoFactorService { private readonly redisClient: Redis; @@ -32,7 +35,7 @@ export class TwoFactorService { async initiateSetup(userId: number, email: string) { const secret = authenticator.generateSecret(); const appName = 'YieldLadder platform'; - const otpauthUrl = authenticator.keyuri(email, appName, secret); + const otpauthUrl = generateURI({ secret, issuer: appName, label: email }); const qrCodeDataUrl = await qrcode.toDataURL(otpauthUrl); // Cache the temporary secret safely inside Redis with a strict 10 minute TTL @@ -132,7 +135,7 @@ export class TwoFactorService { async deactivate(userId: number) { await this.userRepository.update(userId, { - twoFactorSecret: null, + twoFactorSecret: '' as unknown as string, isTwoFactorEnabled: false, }); // Wipe matching system recovery database objects safely diff --git a/backend/src/bids/bids-notifications.service.ts b/backend/src/bids/bids-notifications.service.ts index 889fd951..95c12238 100644 --- a/backend/src/bids/bids-notifications.service.ts +++ b/backend/src/bids/bids-notifications.service.ts @@ -25,11 +25,17 @@ export class BidsNotificationsService { private readonly shipmentRepo: Repository, ) {} - private async sendSafe(to: string, subject: string, html: string): Promise { + private async sendSafe( + to: string, + subject: string, + html: string, + ): Promise { try { await this.mailerService.sendMail({ to, subject, html }); } catch (err: unknown) { - this.logger.warn(`Failed to send email to ${to}: ${err instanceof Error ? err.message : String(err)}`); + this.logger.warn( + `Failed to send email to ${to}: ${err instanceof Error ? err.message : String(err)}`, + ); } } @@ -43,7 +49,10 @@ export class BidsNotificationsService { `; } - private async loadRelations(bid: Bid, shipment: Shipment): Promise<{ carrier: User | null; shipper: User | null }> { + private async loadRelations( + bid: Bid, + shipment: Shipment, + ): Promise<{ carrier: User | null; shipper: User | null }> { const fullShipment = await this.shipmentRepo.findOne({ where: { id: shipment.id }, relations: ['shipper'], diff --git a/backend/src/bids/bids.service.spec.ts b/backend/src/bids/bids.service.spec.ts index afa653ec..5eaf8812 100644 --- a/backend/src/bids/bids.service.spec.ts +++ b/backend/src/bids/bids.service.spec.ts @@ -10,6 +10,7 @@ import { BidsService } from './bids.service'; import { Bid, BidStatus } from './entities/bid.entity'; import { Shipment } from '../shipments/entities/shipment.entity'; import { ShipmentStatus } from '../common/enums/shipment-status.enum'; +import { User } from '../users/entities/user.entity'; const mockBidRepo = () => ({ create: jest.fn(), @@ -24,6 +25,11 @@ const mockShipmentRepo = () => ({ update: jest.fn(), }); +const mockUserRepo = () => ({ + findOne: jest.fn(), + find: jest.fn(), +}); + const mockEventEmitter = () => ({ emit: jest.fn() }); const pendingShipment = (overrides = {}): Partial => ({ @@ -58,6 +64,7 @@ describe('BidsService', () => { BidsService, { provide: getRepositoryToken(Bid), useFactory: mockBidRepo }, { provide: getRepositoryToken(Shipment), useFactory: mockShipmentRepo }, + { provide: getRepositoryToken(User), useFactory: mockUserRepo }, { provide: EventEmitter2, useFactory: mockEventEmitter }, ], }).compile(); @@ -76,7 +83,9 @@ describe('BidsService', () => { bidRepo.create.mockReturnValue(bid); bidRepo.save.mockResolvedValue(bid); - const result = await service.submitBid('ship1', 'carrier1', { proposedPrice: 100 }); + const result = await service.submitBid('ship1', 'carrier1', { + proposedPrice: 100, + }); expect(result).toMatchObject({ id: 'bid1' }); expect(bidRepo.create).toHaveBeenCalledWith( expect.objectContaining({ expiresAt: expect.any(Date) }), @@ -95,7 +104,9 @@ describe('BidsService', () => { it('throws if carrier already has a pending bid', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); bidRepo.findOne.mockResolvedValue(makeBid()); - await expect(service.submitBid('ship1', 'carrier1', { proposedPrice: 100 })).rejects.toThrow(BadRequestException); + await expect( + service.submitBid('ship1', 'carrier1', { proposedPrice: 100 }), + ).rejects.toThrow(BadRequestException); }); }); @@ -140,7 +151,9 @@ describe('BidsService', () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); const expired = makeBid({ expiresAt: new Date(Date.now() - 1000) }); bidRepo.findOne.mockResolvedValue(expired); - await expect(service.acceptBid('ship1', 'bid1', 'shipper1')).rejects.toThrow(BadRequestException); + await expect( + service.acceptBid('ship1', 'bid1', 'shipper1'), + ).rejects.toThrow(BadRequestException); }); it('throws ForbiddenException if not the shipper', async () => { @@ -166,68 +179,110 @@ describe('BidsService', () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); const bid = makeBid(); bidRepo.findOne.mockResolvedValue(bid); - bidRepo.save.mockResolvedValue({ ...bid, status: BidStatus.COUNTER_OFFERED, counterPrice: 90 }); + bidRepo.save.mockResolvedValue({ + ...bid, + status: BidStatus.COUNTER_OFFERED, + counterPrice: 90, + }); - const result = await service.counterBid('ship1', 'bid1', 'shipper1', { counterPrice: 90 }); + const result = await service.counterBid('ship1', 'bid1', 'shipper1', { + counterPrice: 90, + }); expect(result.status).toBe(BidStatus.COUNTER_OFFERED); - expect(eventEmitter.emit).toHaveBeenCalledWith('bid.countered', expect.anything()); + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'bid.countered', + expect.anything(), + ); }); it('throws if bid is expired', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); - bidRepo.findOne.mockResolvedValue(makeBid({ expiresAt: new Date(Date.now() - 1000) })); - await expect(service.counterBid('ship1', 'bid1', 'shipper1', { counterPrice: 90 })).rejects.toThrow(BadRequestException); + bidRepo.findOne.mockResolvedValue( + makeBid({ expiresAt: new Date(Date.now() - 1000) }), + ); + await expect( + service.counterBid('ship1', 'bid1', 'shipper1', { counterPrice: 90 }), + ).rejects.toThrow(BadRequestException); }); it('throws ForbiddenException if not shipper', async () => { - shipmentRepo.findOne.mockResolvedValue(pendingShipment({ shipperId: 'other' })); - await expect(service.counterBid('ship1', 'bid1', 'shipper1', { counterPrice: 90 })).rejects.toThrow(ForbiddenException); + shipmentRepo.findOne.mockResolvedValue( + pendingShipment({ shipperId: 'other' }), + ); + await expect( + service.counterBid('ship1', 'bid1', 'shipper1', { counterPrice: 90 }), + ).rejects.toThrow(ForbiddenException); }); }); describe('acceptCounter', () => { it('accepts the counter, assigns carrier, emits shipment.accepted', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); - const bid = makeBid({ status: BidStatus.COUNTER_OFFERED, counterPrice: 90 }); + const bid = makeBid({ + status: BidStatus.COUNTER_OFFERED, + counterPrice: 90, + }); bidRepo.findOne.mockResolvedValue(bid); - bidRepo.save.mockResolvedValue({ ...bid, status: BidStatus.COUNTER_ACCEPTED }); + bidRepo.save.mockResolvedValue({ + ...bid, + status: BidStatus.COUNTER_ACCEPTED, + }); bidRepo.update.mockResolvedValue(undefined); shipmentRepo.update.mockResolvedValue(undefined); const result = await service.acceptCounter('ship1', 'bid1', 'carrier1'); expect(result.status).toBe(BidStatus.COUNTER_ACCEPTED); - expect(eventEmitter.emit).toHaveBeenCalledWith('shipment.accepted', expect.anything()); + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'shipment.accepted', + expect.anything(), + ); }); it('throws ForbiddenException if not the bid owner', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); - bidRepo.findOne.mockResolvedValue(makeBid({ status: BidStatus.COUNTER_OFFERED })); - await expect(service.acceptCounter('ship1', 'bid1', 'other-carrier')).rejects.toThrow(ForbiddenException); + bidRepo.findOne.mockResolvedValue( + makeBid({ status: BidStatus.COUNTER_OFFERED }), + ); + await expect( + service.acceptCounter('ship1', 'bid1', 'other-carrier'), + ).rejects.toThrow(ForbiddenException); }); it('throws BadRequestException if not in COUNTER_OFFERED status', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); bidRepo.findOne.mockResolvedValue(makeBid({ status: BidStatus.PENDING })); - await expect(service.acceptCounter('ship1', 'bid1', 'carrier1')).rejects.toThrow(BadRequestException); + await expect( + service.acceptCounter('ship1', 'bid1', 'carrier1'), + ).rejects.toThrow(BadRequestException); }); }); describe('declineCounter', () => { it('sets bid to REJECTED and emits event', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); - const bid = makeBid({ status: BidStatus.COUNTER_OFFERED, counterPrice: 90 }); + const bid = makeBid({ + status: BidStatus.COUNTER_OFFERED, + counterPrice: 90, + }); bidRepo.findOne.mockResolvedValue(bid); bidRepo.save.mockResolvedValue({ ...bid, status: BidStatus.REJECTED }); const result = await service.declineCounter('ship1', 'bid1', 'carrier1'); expect(result.status).toBe(BidStatus.REJECTED); - expect(eventEmitter.emit).toHaveBeenCalledWith('bid.counter_declined', expect.anything()); + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'bid.counter_declined', + expect.anything(), + ); }); it('throws ForbiddenException if not the bid owner', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); - bidRepo.findOne.mockResolvedValue(makeBid({ status: BidStatus.COUNTER_OFFERED })); - await expect(service.declineCounter('ship1', 'bid1', 'other')).rejects.toThrow(ForbiddenException); + bidRepo.findOne.mockResolvedValue( + makeBid({ status: BidStatus.COUNTER_OFFERED }), + ); + await expect( + service.declineCounter('ship1', 'bid1', 'other'), + ).rejects.toThrow(ForbiddenException); }); }); }); diff --git a/backend/src/bids/bids.service.ts b/backend/src/bids/bids.service.ts index f1ae72ad..d394fdd6 100644 --- a/backend/src/bids/bids.service.ts +++ b/backend/src/bids/bids.service.ts @@ -47,7 +47,10 @@ export class BidsService { } private async getBidOrFail(bidId: string, shipmentId: string): Promise { - const bid = await this.bidRepo.findOne({ where: { id: bidId, shipmentId }, relations: ['carrier'] }); + const bid = await this.bidRepo.findOne({ + where: { id: bidId, shipmentId }, + relations: ['carrier'], + }); if (!bid) throw new NotFoundException(`Bid ${bidId} not found`); return bid; } @@ -112,7 +115,10 @@ export class BidsService { return this.addIsExpired(saved); } - async getBids(shipmentId: string, requesterId: string): Promise { + async getBids( + shipmentId: string, + requesterId: string, + ): Promise { const shipment = await this.getShipment(shipmentId); if (shipment.shipperId !== requesterId) { throw new ForbiddenException('Only the shipment owner can view bids'); @@ -122,7 +128,7 @@ export class BidsService { relations: ['carrier'], order: { proposedPrice: 'ASC' }, }); - return bids.map(b => this.addIsExpired(b)); + return bids.map((b) => this.addIsExpired(b)); } async acceptBid( @@ -192,7 +198,9 @@ export class BidsService { ): Promise { const shipment = await this.getShipment(shipmentId); if (shipment.shipperId !== requesterId) { - throw new ForbiddenException('Only the shipment owner can make a counteroffer'); + throw new ForbiddenException( + 'Only the shipment owner can make a counteroffer', + ); } const bid = await this.getBidOrFail(bidId, shipmentId); @@ -200,7 +208,9 @@ export class BidsService { throw new BadRequestException('Can only counter a PENDING bid'); } if (this.isBidExpired(bid)) { - throw new BadRequestException('This bid has expired and cannot be countered'); + throw new BadRequestException( + 'This bid has expired and cannot be countered', + ); } bid.counterPrice = dto.counterPrice; @@ -243,7 +253,10 @@ export class BidsService { status: ShipmentStatus.ACCEPTED, }); - this.eventEmitter.emit('shipment.accepted', { shipment, actorId: requesterId }); + this.eventEmitter.emit('shipment.accepted', { + shipment, + actorId: requesterId, + }); return bid; } @@ -257,7 +270,9 @@ export class BidsService { const shipment = await this.getShipment(shipmentId); if (bid.carrierId !== requesterId) { - throw new ForbiddenException('Only the bid owner can decline the counter'); + throw new ForbiddenException( + 'Only the bid owner can decline the counter', + ); } if (bid.status !== BidStatus.COUNTER_OFFERED) { throw new BadRequestException('Bid is not in COUNTER_OFFERED status'); diff --git a/backend/src/bids/entities/bid.entity.ts b/backend/src/bids/entities/bid.entity.ts index ec06b0a7..182488c1 100644 --- a/backend/src/bids/entities/bid.entity.ts +++ b/backend/src/bids/entities/bid.entity.ts @@ -53,7 +53,13 @@ export class Bid { @Column({ type: 'enum', enum: BidStatus, default: BidStatus.PENDING }) status: BidStatus; - @Column({ name: 'counter_price', type: 'decimal', precision: 14, scale: 2, nullable: true }) + @Column({ + name: 'counter_price', + type: 'decimal', + precision: 14, + scale: 2, + nullable: true, + }) counterPrice: number | null; @Column({ name: 'counter_message', type: 'text', nullable: true }) diff --git a/backend/src/messaging/messaging.controller.ts b/backend/src/messaging/messaging.controller.ts index 90371a0a..73816e15 100644 --- a/backend/src/messaging/messaging.controller.ts +++ b/backend/src/messaging/messaging.controller.ts @@ -29,10 +29,7 @@ export class MessagingController { } @Post() - findOrCreate( - @Body() dto: CreateConversationDto, - @CurrentUser() user: User, - ) { + findOrCreate(@Body() dto: CreateConversationDto, @CurrentUser() user: User) { return this.messagingService.findOrCreateConversation(dto, user); } diff --git a/backend/src/messaging/messaging.service.ts b/backend/src/messaging/messaging.service.ts index 0e70593a..1326d004 100644 --- a/backend/src/messaging/messaging.service.ts +++ b/backend/src/messaging/messaging.service.ts @@ -84,10 +84,7 @@ export class MessagingService { }[] > { const conversations = await this.conversationRepo.find({ - where: [ - { shipperId: currentUser.id }, - { carrierId: currentUser.id }, - ], + where: [{ shipperId: currentUser.id }, { carrierId: currentUser.id }], order: { lastMessageAt: 'DESC' }, }); @@ -112,9 +109,7 @@ export class MessagingService { return { id: conv.id, shipmentId: conv.shipmentId, - lastMessage: lastMsg - ? lastMsg.body.substring(0, 80) - : null, + lastMessage: lastMsg ? lastMsg.body.substring(0, 80) : null, lastMessageAt: conv.lastMessageAt, unreadCount, }; diff --git a/backend/src/migrations/1750678212610-BidCounterOfferAndExpiry.ts b/backend/src/migrations/1750678212610-BidCounterOfferAndExpiry.ts index c1a35c9f..4c0698cc 100644 --- a/backend/src/migrations/1750678212610-BidCounterOfferAndExpiry.ts +++ b/backend/src/migrations/1750678212610-BidCounterOfferAndExpiry.ts @@ -1,24 +1,42 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -export class BidCounterOfferAndExpiry1750678212610 implements MigrationInterface { +export class BidCounterOfferAndExpiry1750678212610 + implements MigrationInterface +{ name = 'BidCounterOfferAndExpiry1750678212610'; public async up(queryRunner: QueryRunner): Promise { // Extend the BidStatus enum with new values - await queryRunner.query(`ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'COUNTER_OFFERED'`); - await queryRunner.query(`ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'COUNTER_ACCEPTED'`); - await queryRunner.query(`ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'COUNTER_REJECTED'`); - await queryRunner.query(`ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'EXPIRED'`); + await queryRunner.query( + `ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'COUNTER_OFFERED'`, + ); + await queryRunner.query( + `ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'COUNTER_ACCEPTED'`, + ); + await queryRunner.query( + `ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'COUNTER_REJECTED'`, + ); + await queryRunner.query( + `ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'EXPIRED'`, + ); // Add new columns - await queryRunner.query(`ALTER TABLE "bids" ADD "counter_price" numeric(14,2)`); + await queryRunner.query( + `ALTER TABLE "bids" ADD "counter_price" numeric(14,2)`, + ); await queryRunner.query(`ALTER TABLE "bids" ADD "counter_message" text`); - await queryRunner.query(`ALTER TABLE "bids" ADD "expires_at" TIMESTAMP WITH TIME ZONE`); - await queryRunner.query(`ALTER TABLE "bids" ADD "counter_offered_at" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query( + `ALTER TABLE "bids" ADD "expires_at" TIMESTAMP WITH TIME ZONE`, + ); + await queryRunner.query( + `ALTER TABLE "bids" ADD "counter_offered_at" TIMESTAMP WITH TIME ZONE`, + ); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "bids" DROP COLUMN "counter_offered_at"`); + await queryRunner.query( + `ALTER TABLE "bids" DROP COLUMN "counter_offered_at"`, + ); await queryRunner.query(`ALTER TABLE "bids" DROP COLUMN "expires_at"`); await queryRunner.query(`ALTER TABLE "bids" DROP COLUMN "counter_message"`); await queryRunner.query(`ALTER TABLE "bids" DROP COLUMN "counter_price"`); diff --git a/backend/src/notifications/dto/notification-query.dto.ts b/backend/src/notifications/dto/notification-query.dto.ts index d8dece7d..84a3f48a 100644 --- a/backend/src/notifications/dto/notification-query.dto.ts +++ b/backend/src/notifications/dto/notification-query.dto.ts @@ -1,4 +1,10 @@ -import { IsOptional, IsEnum, IsPositive, IsBoolean, Max } from 'class-validator'; +import { + IsOptional, + IsEnum, + IsPositive, + IsBoolean, + Max, +} from 'class-validator'; import { Type, Transform } from 'class-transformer'; import { ApiPropertyOptional } from '@nestjs/swagger'; import { NotificationType } from '../entities/notification.entity'; @@ -27,4 +33,4 @@ export class NotificationQueryDto { @Transform(({ value }) => value === 'true' || value === true) @IsBoolean() isRead?: boolean; -} \ No newline at end of file +} diff --git a/backend/src/notifications/entities/notification.entity.ts b/backend/src/notifications/entities/notification.entity.ts index a2b78851..0ca3d3a5 100644 --- a/backend/src/notifications/entities/notification.entity.ts +++ b/backend/src/notifications/entities/notification.entity.ts @@ -35,7 +35,11 @@ export class Notification { @JoinColumn({ name: 'userId' }) user: User; - @Column({ type: 'enum', enum: NotificationType, default: NotificationType.GENERAL }) + @Column({ + type: 'enum', + enum: NotificationType, + default: NotificationType.GENERAL, + }) type: NotificationType; @Column({ type: 'varchar', length: 255 }) @@ -52,4 +56,4 @@ export class Notification { @CreateDateColumn({ name: 'created_at' }) createdAt: Date; -} \ No newline at end of file +} diff --git a/backend/src/notifications/notification-inbox.controller.ts b/backend/src/notifications/notification-inbox.controller.ts index 9e04ecb6..ff70e488 100644 --- a/backend/src/notifications/notification-inbox.controller.ts +++ b/backend/src/notifications/notification-inbox.controller.ts @@ -25,8 +25,16 @@ export class NotificationInboxController { @Get() @ApiOperation({ summary: 'Fetch my notification inbox (paginated)' }) - async getInbox(@CurrentUser() user: User, @Query() query: NotificationQueryDto) { - return this.inboxService.findAll(user.id, query.page, query.limit, query.isRead); + async getInbox( + @CurrentUser() user: User, + @Query() query: NotificationQueryDto, + ) { + return this.inboxService.findAll( + user.id, + query.page, + query.limit, + query.isRead, + ); } @Get('unread-count') @@ -54,4 +62,4 @@ export class NotificationInboxController { await this.inboxService.markAllRead(user.id); return { message: 'All notifications marked as read' }; } -} \ No newline at end of file +} diff --git a/backend/src/notifications/notification-inbox.service.ts b/backend/src/notifications/notification-inbox.service.ts index 3c5b4d30..0ca9a9fd 100644 --- a/backend/src/notifications/notification-inbox.service.ts +++ b/backend/src/notifications/notification-inbox.service.ts @@ -33,7 +33,14 @@ export class NotificationInboxService { take: limit, }); const unread = await this.repo.count({ where: { userId, isRead: false } }); - return { items, total, unread, page, limit, totalPages: Math.ceil(total / limit) }; + return { + items, + total, + unread, + page, + limit, + totalPages: Math.ceil(total / limit), + }; } async markRead(id: string, userId: string): Promise { @@ -50,4 +57,4 @@ export class NotificationInboxService { async unreadCount(userId: string): Promise { return this.repo.count({ where: { userId, isRead: false } }); } -} \ No newline at end of file +} diff --git a/backend/src/notifications/notifications.gateway.ts b/backend/src/notifications/notifications.gateway.ts index 207746c9..cab1ed93 100644 --- a/backend/src/notifications/notifications.gateway.ts +++ b/backend/src/notifications/notifications.gateway.ts @@ -21,10 +21,7 @@ import { SHIPMENT_CREATED, ShipmentEvent, } from '../shipments/events/shipment.events'; -import { - MESSAGE_NEW, - MessageNewEvent, -} from '../messaging/messaging.service'; +import { MESSAGE_NEW, MessageNewEvent } from '../messaging/messaging.service'; import { JwtPayload } from '../auth/strategies/jwt.strategy'; /** Room prefix for per-user rooms */ diff --git a/backend/src/notifications/notifications.module.ts b/backend/src/notifications/notifications.module.ts index f2532896..209b14c0 100644 --- a/backend/src/notifications/notifications.module.ts +++ b/backend/src/notifications/notifications.module.ts @@ -24,7 +24,11 @@ import { Notification } from './entities/notification.entity'; }), ], controllers: [NotificationInboxController], - providers: [NotificationsService, NotificationsGateway, NotificationInboxService], + providers: [ + NotificationsService, + NotificationsGateway, + NotificationInboxService, + ], exports: [NotificationInboxService], }) -export class NotificationsModule {} \ No newline at end of file +export class NotificationsModule {} diff --git a/backend/src/reviews/reviews.service.spec.ts b/backend/src/reviews/reviews.service.spec.ts index a02c24ed..676a5ba0 100644 --- a/backend/src/reviews/reviews.service.spec.ts +++ b/backend/src/reviews/reviews.service.spec.ts @@ -33,6 +33,9 @@ function makeUser(overrides: Partial = {}): User { resetPasswordExpiry: null, createdAt: new Date(), updatedAt: new Date(), + isTwoFactorEnabled: false, + twoFactorSecret: undefined as any, + recoveryCodes: [], ...overrides, }; } diff --git a/backend/src/shipments/shipments.analytics.spec.ts b/backend/src/shipments/shipments.analytics.spec.ts index f4466200..92efcbe7 100644 --- a/backend/src/shipments/shipments.analytics.spec.ts +++ b/backend/src/shipments/shipments.analytics.spec.ts @@ -30,6 +30,9 @@ function makeUser(overrides: Partial = {}): User { resetPasswordExpiry: null, createdAt: new Date(), updatedAt: new Date(), + isTwoFactorEnabled: false, + twoFactorSecret: undefined as any, + recoveryCodes: [], ...overrides, }; } diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index 92c0fdd6..29132ca9 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -30,6 +30,9 @@ function makeUser(overrides: Partial = {}): User { resetPasswordExpiry: null, createdAt: new Date(), updatedAt: new Date(), + isTwoFactorEnabled: false, + twoFactorSecret: undefined as any, + recoveryCodes: [], ...overrides, }; } diff --git a/backend/src/webhooks/webhooks.service.spec.ts b/backend/src/webhooks/webhooks.service.spec.ts index 3811ba35..6a403dd9 100644 --- a/backend/src/webhooks/webhooks.service.spec.ts +++ b/backend/src/webhooks/webhooks.service.spec.ts @@ -30,6 +30,9 @@ function makeUser(): User { resetPasswordExpiry: null, createdAt: new Date(), updatedAt: new Date(), + isTwoFactorEnabled: false, + twoFactorSecret: undefined as any, + recoveryCodes: [], }; } diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts new file mode 100644 index 00000000..de650344 --- /dev/null +++ b/backend/test/app.e2e-spec.ts @@ -0,0 +1,26 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('AppController (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('/ (GET)', () => { + const httpServer = app.getHttpServer(); + return request(httpServer).get('/').expect(200).expect('Hello World!'); + }); +}); diff --git a/backend/test/jest-e2e.json b/backend/test/jest-e2e.json new file mode 100644 index 00000000..f1aee95e --- /dev/null +++ b/backend/test/jest-e2e.json @@ -0,0 +1,12 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "jest": { + "testTimeout": 30000 + } +} diff --git a/frontend/app/(dashboard)/bids/page.tsx b/frontend/app/(dashboard)/bids/page.tsx index 9d9c0068..c6db6757 100644 --- a/frontend/app/(dashboard)/bids/page.tsx +++ b/frontend/app/(dashboard)/bids/page.tsx @@ -1,27 +1,40 @@ -'use client'; +"use client"; -import { useCallback, useEffect, useRef, useState } from 'react'; -import Link from 'next/link'; -import { toast } from 'sonner'; -import { bidApi, Bid, BidStatus } from '../../../lib/api/bid.api'; -import { Card, CardContent, CardHeader, CardTitle } from '../../../components/ui/card'; -import { Button } from '../../../components/ui/button'; +import { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { toast } from "sonner"; +import { bidApi, Bid, BidStatus } from "../../../lib/api/bid.api"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "../../../components/ui/card"; +import { Button } from "../../../components/ui/button"; +import { TableRowSkeleton } from "../../../components/skeletons"; +import { EmptyBids } from "../../../components/ui/empty-state"; function getBidStatusClass(status: BidStatus): string { switch (status) { - case 'PENDING': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'; - case 'ACCEPTED': - case 'COUNTER_ACCEPTED': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'; - case 'REJECTED': - case 'COUNTER_REJECTED': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'; - case 'COUNTER_OFFERED': return 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'; - case 'EXPIRED': return 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'; - default: return 'bg-muted text-muted-foreground'; + case "PENDING": + return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300"; + case "ACCEPTED": + case "COUNTER_ACCEPTED": + return "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"; + case "REJECTED": + case "COUNTER_REJECTED": + return "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"; + case "COUNTER_OFFERED": + return "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"; + case "EXPIRED": + return "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"; + default: + return "bg-muted text-muted-foreground"; } } function ExpiryCountdown({ expiresAt }: { expiresAt?: string }) { - const [label, setLabel] = useState(''); + const [label, setLabel] = useState(""); useEffect(() => { if (!expiresAt) return; @@ -29,7 +42,7 @@ function ExpiryCountdown({ expiresAt }: { expiresAt?: string }) { const update = () => { const diff = new Date(expiresAt).getTime() - Date.now(); if (diff <= 0) { - setLabel('Expired'); + setLabel("Expired"); return; } const hours = Math.floor(diff / 3_600_000); @@ -43,9 +56,11 @@ function ExpiryCountdown({ expiresAt }: { expiresAt?: string }) { }, [expiresAt]); if (!expiresAt) return null; - const expired = label === 'Expired'; + const expired = label === "Expired"; return ( - + {label} ); @@ -55,7 +70,10 @@ export default function BidsDashboardPage() { const [bids, setBids] = useState([]); const [loading, setLoading] = useState(true); const [actionLoading, setActionLoading] = useState(null); - const confirmRef = useRef<{ bidId: string; action: 'accept' | 'decline' } | null>(null); + const confirmRef = useRef<{ + bidId: string; + action: "accept" | "decline"; + } | null>(null); const [showConfirm, setShowConfirm] = useState(false); const load = useCallback(async () => { @@ -63,15 +81,17 @@ export default function BidsDashboardPage() { const data = await bidApi.listMyBids(); setBids(data); } catch { - toast.error('Failed to load bids'); + toast.error("Failed to load bids"); } finally { setLoading(false); } }, []); - useEffect(() => { load(); }, [load]); + useEffect(() => { + load(); + }, [load]); - const handleCounterAction = (bidId: string, action: 'accept' | 'decline') => { + const handleCounterAction = (bidId: string, action: "accept" | "decline") => { confirmRef.current = { bidId, action }; setShowConfirm(true); }; @@ -85,16 +105,16 @@ export default function BidsDashboardPage() { setShowConfirm(false); setActionLoading(ref.bidId); try { - if (ref.action === 'accept') { + if (ref.action === "accept") { await bidApi.acceptCounter(bid.shipmentId, bid.id); - toast.success('Counter offer accepted'); + toast.success("Counter offer accepted"); } else { await bidApi.declineCounter(bid.shipmentId, bid.id); - toast.success('Counter offer declined'); + toast.success("Counter offer declined"); } await load(); } catch { - toast.error('Action failed. Please try again.'); + toast.error("Action failed. Please try again."); } finally { setActionLoading(null); } @@ -104,9 +124,13 @@ export default function BidsDashboardPage() { return (
- {[...Array(3)].map((_, i) => ( -
- ))} + + + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + +
); } @@ -116,11 +140,7 @@ export default function BidsDashboardPage() {

My Bids

{bids.length === 0 ? ( - - - You haven't submitted any bids yet. - - + ) : ( @@ -132,9 +152,15 @@ export default function BidsDashboardPage() { Route - Tracking - Your Price - Counter + + Tracking + + + Your Price + + + Counter + Status Expiry Actions @@ -142,11 +168,14 @@ export default function BidsDashboardPage() { {bids.map((bid) => ( - + {bid.shipment ? `${bid.shipment.origin} → ${bid.shipment.destination}` - : '—'} + : "—"} {bid.shipment ? ( @@ -156,7 +185,9 @@ export default function BidsDashboardPage() { > {bid.shipment.trackingNumber} - ) : '—'} + ) : ( + "—" + )} ${Number(bid.proposedPrice).toLocaleString()} @@ -166,11 +197,15 @@ export default function BidsDashboardPage() { ${Number(bid.counterPrice).toLocaleString()} - ) : '—'} + ) : ( + "—" + )} - - {bid.status.replace('_', ' ')} + + {bid.status.replace("_", " ")} @@ -178,12 +213,14 @@ export default function BidsDashboardPage() {
- {bid.status === 'COUNTER_OFFERED' && ( + {bid.status === "COUNTER_OFFERED" && ( <> @@ -191,7 +228,9 @@ export default function BidsDashboardPage() { size="sm" variant="outline" disabled={actionLoading === bid.id} - onClick={() => handleCounterAction(bid.id, 'decline')} + onClick={() => + handleCounterAction(bid.id, "decline") + } > Decline Counter @@ -225,14 +264,16 @@ export default function BidsDashboardPage() { - {confirmRef.current?.action === 'accept' ? 'Accept Counter Offer?' : 'Decline Counter Offer?'} + {confirmRef.current?.action === "accept" + ? "Accept Counter Offer?" + : "Decline Counter Offer?"}

- {confirmRef.current?.action === 'accept' - ? 'This will accept the counter offer and update the bid status.' - : 'This will decline the counter offer.'} + {confirmRef.current?.action === "accept" + ? "This will accept the counter offer and update the bid status." + : "This will decline the counter offer."}

+ + Refresh dashboard + +
+
+
+ ); +} diff --git a/frontend/app/(dashboard)/marketplace/page.tsx b/frontend/app/(dashboard)/marketplace/page.tsx index 9945074c..6857f039 100644 --- a/frontend/app/(dashboard)/marketplace/page.tsx +++ b/frontend/app/(dashboard)/marketplace/page.tsx @@ -1,71 +1,79 @@ -'use client'; - -import { useEffect, useState, useCallback } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { shipmentApi } from '../../../lib/api/shipment.api'; -import { ShipmentCard } from '../../../components/shipment/shipment-card'; -import { ShipmentCardSkeleton } from '../../../components/ui/skeleton'; -import { Input } from '../../../components/ui/input'; -import { Button } from '../../../components/ui/button'; -import { toast } from 'sonner'; -import type { QueryShipmentParams } from '../../../types/shipment.types'; +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { shipmentApi } from "../../../lib/api/shipment.api"; +import { ShipmentCard } from "../../../components/shipment/shipment-card"; +import { ShipmentCardSkeleton } from "../../../components/skeletons"; +import { Input } from "../../../components/ui/input"; +import { Button } from "../../../components/ui/button"; +import { EmptyMarketplace } from "../../../components/ui/empty-state"; +import { toast } from "sonner"; +import type { QueryShipmentParams } from "../../../types/shipment.types"; const CARGO_CATEGORIES = [ - 'All', - 'Electronics', - 'Furniture', - 'Food & Beverage', - 'Clothing', - 'Machinery', - 'Chemicals', - 'Automotive', - 'Medical', - 'Other', + "All", + "Electronics", + "Furniture", + "Food & Beverage", + "Clothing", + "Machinery", + "Chemicals", + "Automotive", + "Medical", + "Other", ]; -type SortOption = 'price_asc' | 'price_desc' | 'date_asc' | 'date_desc'; +type SortOption = "price_asc" | "price_desc" | "date_asc" | "date_desc"; const SORT_OPTIONS: { label: string; value: SortOption }[] = [ - { label: 'Price: Low → High', value: 'price_asc' }, - { label: 'Price: High → Low', value: 'price_desc' }, - { label: 'Newest First', value: 'date_desc' }, - { label: 'Oldest First', value: 'date_asc' }, + { label: "Price: Low → High", value: "price_asc" }, + { label: "Price: High → Low", value: "price_desc" }, + { label: "Newest First", value: "date_desc" }, + { label: "Oldest First", value: "date_asc" }, ]; export default function MarketplacePage() { - const [origin, setOrigin] = useState(''); - const [destination, setDestination] = useState(''); - const [cargoCategory, setCargoCategory] = useState('All'); - const [minPrice, setMinPrice] = useState(''); - const [maxPrice, setMaxPrice] = useState(''); - const [sort, setSort] = useState('date_desc'); + const [origin, setOrigin] = useState(""); + const [destination, setDestination] = useState(""); + const [cargoCategory, setCargoCategory] = useState("All"); + const [minPrice, setMinPrice] = useState(""); + const [maxPrice, setMaxPrice] = useState(""); + const [sort, setSort] = useState("date_desc"); const [page, setPage] = useState(1); const [filters, setFilters] = useState({ page: 1, limit: 12, }); - const { data: result, isLoading, error } = useQuery({ - queryKey: ['marketplace', filters], + const { + data: result, + isLoading, + error, + } = useQuery({ + queryKey: ["marketplace", filters], queryFn: () => shipmentApi.marketplace({ ...filters, page: filters.page }), }); useEffect(() => { - if (error) toast.error('Failed to load marketplace'); + if (error) toast.error("Failed to load marketplace"); }, [error]); - const applyFilters = useCallback((pg = 1) => { - setPage(pg); - setFilters({ - origin: origin || undefined, - destination: destination || undefined, - page: pg, - limit: 12, - cargoCategory: cargoCategory !== 'All' ? cargoCategory : undefined, - minPrice: minPrice ? Number(minPrice) : undefined, - maxPrice: maxPrice ? Number(maxPrice) : undefined, - }); - }, [origin, destination, cargoCategory, minPrice, maxPrice]); + const applyFilters = useCallback( + (pg = 1) => { + setPage(pg); + setFilters({ + origin: origin || undefined, + destination: destination || undefined, + page: pg, + limit: 12, + cargoCategory: cargoCategory !== "All" ? cargoCategory : undefined, + minPrice: minPrice ? Number(minPrice) : undefined, + maxPrice: maxPrice ? Number(maxPrice) : undefined, + }); + }, + [origin, destination, cargoCategory, minPrice, maxPrice], + ); const handleSearch = (e: React.FormEvent) => { e.preventDefault(); @@ -73,25 +81,33 @@ export default function MarketplacePage() { }; const handleClear = () => { - setOrigin(''); - setDestination(''); - setCargoCategory('All'); - setMinPrice(''); - setMaxPrice(''); - setSort('date_desc'); + setOrigin(""); + setDestination(""); + setCargoCategory("All"); + setMinPrice(""); + setMaxPrice(""); + setSort("date_desc"); setPage(1); setFilters({ page: 1, limit: 12 }); }; // Client-side sort (API may not support all sort params) - const sorted = result?.data ? [...result.data].sort((a, b) => { - if (sort === 'price_asc') return Number(a.price) - Number(b.price); - if (sort === 'price_desc') return Number(b.price) - Number(a.price); - if (sort === 'date_asc') return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - }) : []; - - const hasFilters = origin || destination || cargoCategory !== 'All' || minPrice || maxPrice; + const sorted = result?.data + ? [...result.data].sort((a, b) => { + if (sort === "price_asc") return Number(a.price) - Number(b.price); + if (sort === "price_desc") return Number(b.price) - Number(a.price); + if (sort === "date_asc") + return ( + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + return ( + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + }) + : []; + + const hasFilters = + origin || destination || cargoCategory !== "All" || minPrice || maxPrice; return (
@@ -123,7 +139,9 @@ export default function MarketplacePage() { className="text-sm bg-background border border-border rounded-md px-3 py-2 text-foreground" > {CARGO_CATEGORIES.map((c) => ( - + ))} {SORT_OPTIONS.map((o) => ( - + ))} {hasFilters && ( - )} @@ -170,11 +195,7 @@ export default function MarketplacePage() { ))}
) : !result || sorted.length === 0 ? ( -
-

- No available shipments right now. Check back soon! -

-
+ ) : ( <>
@@ -208,7 +229,7 @@ export default function MarketplacePage() { )}

- {result.total} shipment{result.total !== 1 ? 's' : ''} available + {result.total} shipment{result.total !== 1 ? "s" : ""} available

)} diff --git a/frontend/app/(dashboard)/settings/security/page.tsx b/frontend/app/(dashboard)/settings/security/page.tsx index 8d2eab14..f6c2c0d9 100644 --- a/frontend/app/(dashboard)/settings/security/page.tsx +++ b/frontend/app/(dashboard)/settings/security/page.tsx @@ -1,108 +1,140 @@ -'use client' +"use client"; -import * as React from 'react' -import { authApi, Setup2FAResponse } from '@/lib/api/auth.api' -import { - ShieldAlert, ShieldCheck, Copy, Check, - Download, Loader2, KeyRound, Smartphone, AlertTriangle -} from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { Checkbox } from '@/components/ui/checkbox' -import { Separator } from '@/components/ui/separator' +import * as React from "react"; +import { toast } from "sonner"; +import { authApi, Setup2FAResponse } from "@/lib/api/auth.api"; +import { + ShieldAlert, + ShieldCheck, + Copy, + Check, + Download, + Loader2, + KeyRound, + Smartphone, + AlertTriangle, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Separator } from "@/components/ui/separator"; export default function SecuritySettingsPage() { - const [is2FAEnabled, setIs2FAEnabled] = React.useState(false) // Default updated via profile check hooks - const [loading, setLoading] = React.useState(false) - const [copiedText, setCopiedText] = React.useState(false) + const [is2FAEnabled, setIs2FAEnabled] = React.useState(false); // Default updated via profile check hooks + const [loading, setLoading] = React.useState(false); + const [copiedText, setCopiedText] = React.useState(false); // Modals state engines - const [setupModalOpen, setSetupModalOpen] = React.useState(false) - const [disableModalOpen, setDisableModalOpen] = React.useState(false) + const [setupModalOpen, setSetupModalOpen] = React.useState(false); + const [disableModalOpen, setDisableModalOpen] = + React.useState(false); // Setup Wizard State Tracking - const [step, setStep] = React.useState<1 | 2 | 3>(1) - const [setupData, setSetupData] = React.useState(null) - const [otpValue, setOtpValue] = React.useState('') - const [recoveryCodes, setRecoveryCodes] = React.useState([]) - const [confirmSavedCodes, setConfirmSavedCodes] = React.useState(false) - const [inlineError, setInlineError] = React.useState(null) + const [step, setStep] = React.useState<1 | 2 | 3>(1); + const [setupData, setSetupData] = React.useState( + null, + ); + const [otpValue, setOtpValue] = React.useState(""); + const [recoveryCodes, setRecoveryCodes] = React.useState([]); + const [confirmSavedCodes, setConfirmSavedCodes] = + React.useState(false); + const [inlineError, setInlineError] = React.useState(null); // Disable Flow State Tracking - const [confirmPassword, setConfirmPassword] = React.useState('') + const [confirmPassword, setConfirmPassword] = React.useState(""); const handleOpenSetup = async () => { try { - setLoading(true) - setInlineError(null) - const data = await authApi.setup2FA() - setSetupData(data) - setStep(1) - setSetupModalOpen(true) + setLoading(true); + setInlineError(null); + const data = await authApi.setup2FA(); + setSetupData(data); + setStep(1); + setSetupModalOpen(true); } catch (err: unknown) { - alert((err as {message?: string}).message || 'Could not initialize 2FA configuration.') + toast.error( + (err as { message?: string }).message || + "Could not initialize 2FA configuration.", + ); } finally { - setLoading(false) + setLoading(false); } - } + }; const handleVerifyEnable = async (e: React.FormEvent) => { - e.preventDefault() - if (otpValue.length !== 6) return + e.preventDefault(); + if (otpValue.length !== 6) return; try { - setLoading(true) - setInlineError(null) - const data = await authApi.enable2FA(otpValue) - setRecoveryCodes(data.recoveryCodes) - setIs2FAEnabled(true) - setStep(3) + setLoading(true); + setInlineError(null); + const data = await authApi.enable2FA(otpValue); + setRecoveryCodes(data.recoveryCodes); + setIs2FAEnabled(true); + setStep(3); } catch (err: unknown) { - setInlineError((err as {message?: string}).message ?? null) + setInlineError((err as { message?: string }).message ?? null); } finally { - setLoading(false) + setLoading(false); } - } + }; const handleDisable2FA = async (e: React.FormEvent) => { - e.preventDefault() + e.preventDefault(); try { - setLoading(true) - setInlineError(null) - await authApi.disable2FA(confirmPassword) - setIs2FAEnabled(false) - setDisableModalOpen(false) - setConfirmPassword('') + setLoading(true); + setInlineError(null); + await authApi.disable2FA(confirmPassword); + setIs2FAEnabled(false); + setDisableModalOpen(false); + setConfirmPassword(""); } catch (err: unknown) { - setInlineError((err as {message?: string}).message ?? null) + setInlineError((err as { message?: string }).message ?? null); } finally { - setLoading(false) + setLoading(false); } - } + }; const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text) - setCopiedText(true) - setTimeout(() => setCopiedText(false), 2000) - } + navigator.clipboard.writeText(text); + setCopiedText(true); + setTimeout(() => setCopiedText(false), 2000); + }; const downloadRecoveryCodesTxt = () => { - const element = document.createElement("a") - const file = new Blob([recoveryCodes.join("\n")], { type: 'text/plain' }) - element.href = URL.createObjectURL(file) - element.download = "yieldladder-recovery-codes.txt" - document.body.appendChild(element) - element.click() - document.body.removeChild(element) - } + const element = document.createElement("a"); + const file = new Blob([recoveryCodes.join("\n")], { type: "text/plain" }); + element.href = URL.createObjectURL(file); + element.download = "yieldladder-recovery-codes.txt"; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); + }; return (
-

Security Credentials Layout

-

Manage multi-factor verification keys and authorization checkpoints.

+

+ Security Credentials Layout +

+

+ Manage multi-factor verification keys and authorization checkpoints. +

@@ -111,9 +143,13 @@ export default function SecuritySettingsPage() {
- Two-Factor Authentication (2FA) + Two-Factor + Authentication (2FA) - Secure your ecosystem account using standard time-based tokens (TOTP). + + Secure your ecosystem account using standard time-based tokens + (TOTP). +
{is2FAEnabled ? ( @@ -121,7 +157,10 @@ export default function SecuritySettingsPage() { Active ) : ( - + Disabled )} @@ -129,30 +168,56 @@ export default function SecuritySettingsPage() { {is2FAEnabled ? ( -

Your account context is verified using a hardware or software authenticator app before granting entry credentials.

+

+ Your account context is verified using a hardware or software + authenticator app before granting entry credentials. +

) : ( -

Multi-factor verification is not configured yet. We recommend enabling 2FA to protect your transactions and balances.

+

+ Multi-factor verification is not configured yet. We recommend + enabling 2FA to protect your transactions and balances. +

)}
{is2FAEnabled ? ( - ) : ( - )} {/* Dynamic 3-Step Setup Verification Modal */} - !loading && step !== 3 && setSetupModalOpen(open)}> + + !loading && step !== 3 && setSetupModalOpen(open) + } + > Configure Authenticators - Enhance your account security using a 3-step setup framework. + + Enhance your account security using a 3-step setup framework. + {inlineError && ( @@ -163,41 +228,85 @@ export default function SecuritySettingsPage() { {step === 1 && setupData && (
-
Step 1: Scan this QR matrix using your mobile authenticator app (Google Authenticator, Duo, or 1Password).
+
+ Step 1: Scan this QR matrix using your mobile authenticator app + (Google Authenticator, Duo, or 1Password). +
- 2FA Setup QR Matrix + 2FA Setup QR Matrix
- +
- -
- +
)} {step === 2 && (
-
Step 2: Enter the 6-digit confirmation token visible inside your app.
+
+ Step 2: Enter the 6-digit confirmation token visible inside your + app. +
- setOtpValue(e.target.value.replace(/\D/g, ''))} + placeholder="000000" + value={otpValue} + onChange={(e) => + setOtpValue(e.target.value.replace(/\D/g, "")) + } className="text-center font-mono text-lg tracking-widest" />
- - +
@@ -208,33 +317,59 @@ export default function SecuritySettingsPage() {
- Warning: Keep these emergency backup recovery phrases safe. They will not be displayed again. + Warning: Keep these emergency backup recovery + phrases safe. They will not be displayed again.
- {recoveryCodes.map((code, idx) =>
{code}
)} + {recoveryCodes.map((code, idx) => ( +
+ {code} +
+ ))}
- -
- setConfirmSavedCodes(!!checked)} /> -
-
@@ -243,13 +378,19 @@ export default function SecuritySettingsPage() { {/* Danger-Zone Deactivation Confirmation Modal */} - !loading && setDisableModalOpen(open)}> + !loading && setDisableModalOpen(open)} + > Confirm Deactivation - To disable 2FA protections, confirm your primary identity profile parameters below. + + To disable 2FA protections, confirm your primary identity profile + parameters below. + {inlineError && ( @@ -260,24 +401,40 @@ export default function SecuritySettingsPage() {
- - setConfirmPassword(e.target.value)} + + setConfirmPassword(e.target.value)} />
- - +
- ) -} \ No newline at end of file + ); +} diff --git a/frontend/app/(dashboard)/shipments/page.tsx b/frontend/app/(dashboard)/shipments/page.tsx index 3e937bfa..02a41419 100644 --- a/frontend/app/(dashboard)/shipments/page.tsx +++ b/frontend/app/(dashboard)/shipments/page.tsx @@ -1,46 +1,50 @@ -'use client'; +"use client"; -import { useEffect, useState } from 'react'; -import Link from 'next/link'; -import { useAuthStore } from '../../../stores/auth.store'; -import { shipmentApi } from '../../../lib/api/shipment.api'; -import { ShipmentStatus, PaginatedShipments } from '../../../types/shipment.types'; -import { ShipmentCard } from '../../../components/shipment/shipment-card'; -import { ShipmentCardSkeleton } from '../../../components/ui/skeleton'; -import { Button } from '../../../components/ui/button'; -import { toast } from 'sonner'; -import { apiClient } from '../../../lib/api/client'; +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useAuthStore } from "../../../stores/auth.store"; +import { shipmentApi } from "../../../lib/api/shipment.api"; +import { + ShipmentStatus, + PaginatedShipments, +} from "../../../types/shipment.types"; +import { ShipmentCard } from "../../../components/shipment/shipment-card"; +import { ShipmentCardSkeleton } from "../../../components/skeletons"; +import { Button } from "../../../components/ui/button"; +import { EmptyShipments } from "../../../components/ui/empty-state"; +import { toast } from "sonner"; +import { apiClient } from "../../../lib/api/client"; -const STATUS_TABS: { label: string; value: ShipmentStatus | 'all' }[] = [ - { label: 'All', value: 'all' }, - { label: 'Pending', value: ShipmentStatus.PENDING }, - { label: 'Accepted', value: ShipmentStatus.ACCEPTED }, - { label: 'In Transit', value: ShipmentStatus.IN_TRANSIT }, - { label: 'Delivered', value: ShipmentStatus.DELIVERED }, - { label: 'Completed', value: ShipmentStatus.COMPLETED }, +const STATUS_TABS: { label: string; value: ShipmentStatus | "all" }[] = [ + { label: "All", value: "all" }, + { label: "Pending", value: ShipmentStatus.PENDING }, + { label: "Accepted", value: ShipmentStatus.ACCEPTED }, + { label: "In Transit", value: ShipmentStatus.IN_TRANSIT }, + { label: "Delivered", value: ShipmentStatus.DELIVERED }, + { label: "Completed", value: ShipmentStatus.COMPLETED }, ]; export default function ShipmentsPage() { const { user } = useAuthStore(); const [result, setResult] = useState(null); - const [activeTab, setActiveTab] = useState('all'); + const [activeTab, setActiveTab] = useState("all"); const [loading, setLoading] = useState(true); const [exporting, setExporting] = useState(false); const exportCsv = async () => { setExporting(true); try { - const blob = await apiClient('/shipments/export?format=csv', { - headers: { Accept: 'text/csv' }, + const blob = await apiClient("/shipments/export?format=csv", { + headers: { Accept: "text/csv" }, }); const url = URL.createObjectURL(blob); - const a = document.createElement('a'); + const a = document.createElement("a"); a.href = url; - a.download = 'shipments.csv'; + a.download = "shipments.csv"; a.click(); URL.revokeObjectURL(url); } catch { - toast.error('Failed to export CSV. Please try again.'); + toast.error("Failed to export CSV. Please try again."); } finally { setExporting(false); } @@ -49,14 +53,14 @@ export default function ShipmentsPage() { useEffect(() => { setLoading(true); shipmentApi - .list({ status: activeTab === 'all' ? undefined : activeTab }) + .list({ status: activeTab === "all" ? undefined : activeTab }) .then(setResult) - .catch(() => toast.error('Failed to load shipments')) + .catch(() => toast.error("Failed to load shipments")) .finally(() => setLoading(false)); }, [activeTab]); - const isShipper = user?.role === 'shipper' || user?.role === 'admin'; - const pageTitle = user?.role === 'carrier' ? 'My Jobs' : 'My Shipments'; + const isShipper = user?.role === "shipper" || user?.role === "admin"; + const pageTitle = user?.role === "carrier" ? "My Jobs" : "My Shipments"; return (
@@ -73,14 +77,30 @@ export default function ShipmentsPage() { > {exporting ? ( <> - - - + + + Exporting… ) : ( - 'Export CSV' + "Export CSV" )} {isShipper && ( @@ -99,8 +119,8 @@ export default function ShipmentsPage() { onClick={() => setActiveTab(tab.value)} className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${ activeTab === tab.value - ? 'border-primary text-primary' - : 'border-transparent text-muted-foreground hover:text-foreground' + ? "border-primary text-primary" + : "border-transparent text-muted-foreground hover:text-foreground" }`} > {tab.label} @@ -116,18 +136,13 @@ export default function ShipmentsPage() { ))}
) : !result || result.data.length === 0 ? ( -
-

- {activeTab === 'all' - ? 'No shipments yet.' - : `No shipments with status "${activeTab}".`} -

- {isShipper && activeTab === 'all' && ( - - )} -
+ (window.location.href = "/shipments/new") + : undefined + } + /> ) : ( <>
diff --git a/frontend/app/global-error.tsx b/frontend/app/global-error.tsx new file mode 100644 index 00000000..38dfdc8f --- /dev/null +++ b/frontend/app/global-error.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect } from "react"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + // Log to Sentry or other monitoring service in production + console.error("[GlobalError]", error); + }, [error]); + + return ( + + +
+
+ {/* Error icon */} +
+
+ ! +
+
+ + {/* Message */} +
+

+ Critical error occurred +

+

+ {error.message || + "A critical application error occurred. Please try refreshing the page."} +

+ {error.digest && ( +

+ Error ID: {error.digest} +

+ )} +
+ + {/* Actions */} +
+ + + Go to Dashboard + +
+
+
+ + + ); +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index d557fb7c..d430b41b 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -2,7 +2,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Toaster } from "sonner"; import { QueryProvider } from "../providers/query-provider"; -import ToastContainer from "../components/ui/ToastContainer"; import "./globals.css"; const geistSans = Geist({ @@ -40,8 +39,7 @@ export default function RootLayout({ > {children} - - + diff --git a/frontend/app/not-found.tsx b/frontend/app/not-found.tsx index 8b477e90..80ab8448 100644 --- a/frontend/app/not-found.tsx +++ b/frontend/app/not-found.tsx @@ -1,22 +1,113 @@ -import Link from 'next/link'; +import Link from "next/link"; export default function NotFound() { return (
- {/* Logo */} + {/* Truck illustration */}
-
- FF -
+
{/* Heading */}
-

404

-

Page not found

+

Page Not Found

- The page you're looking for doesn't exist or has been moved. + The page you're looking for doesn't exist or has been + moved.

@@ -29,10 +120,10 @@ export default function NotFound() { Go to Dashboard - View Shipments + Go to Home
diff --git a/frontend/components/skeletons/KpiCardSkeleton.tsx b/frontend/components/skeletons/KpiCardSkeleton.tsx new file mode 100644 index 00000000..f30b9304 --- /dev/null +++ b/frontend/components/skeletons/KpiCardSkeleton.tsx @@ -0,0 +1,12 @@ +import { Skeleton } from "../ui/skeleton"; + +/** Skeleton for a KPI card (matches dashboard metric cards) */ +export function KpiCardSkeleton() { + return ( +
+ + + +
+ ); +} diff --git a/frontend/components/skeletons/NotificationItemSkeleton.tsx b/frontend/components/skeletons/NotificationItemSkeleton.tsx new file mode 100644 index 00000000..a806ca16 --- /dev/null +++ b/frontend/components/skeletons/NotificationItemSkeleton.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from "../ui/skeleton"; + +/** Skeleton for a notification item/row */ +export function NotificationItemSkeleton() { + return ( +
+ +
+ + + +
+
+ ); +} diff --git a/frontend/components/skeletons/ProfileSkeleton.tsx b/frontend/components/skeletons/ProfileSkeleton.tsx new file mode 100644 index 00000000..4cbad572 --- /dev/null +++ b/frontend/components/skeletons/ProfileSkeleton.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from "../ui/skeleton"; + +/** Skeleton for a profile section (avatar + text lines) */ +export function ProfileSkeleton() { + return ( +
+ +
+ + +
+
+ ); +} diff --git a/frontend/components/skeletons/TableRowSkeleton.tsx b/frontend/components/skeletons/TableRowSkeleton.tsx new file mode 100644 index 00000000..6cfb8629 --- /dev/null +++ b/frontend/components/skeletons/TableRowSkeleton.tsx @@ -0,0 +1,19 @@ +import { Skeleton } from "../ui/skeleton"; + +interface TableRowSkeletonProps { + columns?: number; +} + +/** Generic skeleton for a data table row with configurable columns */ +export function TableRowSkeleton({ columns = 5 }: TableRowSkeletonProps) { + return ( +
+ {Array.from({ length: columns }).map((_, i) => ( + + ))} +
+ ); +} diff --git a/frontend/components/skeletons/index.ts b/frontend/components/skeletons/index.ts new file mode 100644 index 00000000..ff59d3d9 --- /dev/null +++ b/frontend/components/skeletons/index.ts @@ -0,0 +1,12 @@ +export { + Skeleton, + ShipmentCardSkeleton, + ShipmentTableRowSkeleton, + UserTableRowSkeleton, + StatsCardSkeleton, +} from "../ui/skeleton"; + +export { KpiCardSkeleton } from "./KpiCardSkeleton"; +export { ProfileSkeleton } from "./ProfileSkeleton"; +export { NotificationItemSkeleton } from "./NotificationItemSkeleton"; +export { TableRowSkeleton } from "./TableRowSkeleton"; diff --git a/frontend/components/ui/empty-state.tsx b/frontend/components/ui/empty-state.tsx index 37deafa9..933aef6b 100644 --- a/frontend/components/ui/empty-state.tsx +++ b/frontend/components/ui/empty-state.tsx @@ -1,5 +1,6 @@ -import { ReactNode } from 'react'; -import { Button } from './button'; +import { ReactNode } from "react"; +import Link from "next/link"; +import { Button } from "./button"; interface EmptyStateProps { illustration?: ReactNode; @@ -7,11 +8,17 @@ interface EmptyStateProps { description?: string; cta?: { label: string; - onClick: () => void; + onClick?: () => void; + href?: string; }; } -export function EmptyState({ illustration, title, description, cta }: EmptyStateProps) { +export function EmptyState({ + illustration, + title, + description, + cta, +}: EmptyStateProps) { return (
{illustration && ( @@ -22,14 +29,21 @@ export function EmptyState({ illustration, title, description, cta }: EmptyState

{title}

{description && ( -

{description}

+

+ {description} +

)}
- {cta && ( - - )} + {cta && + (cta.href ? ( + + ) : cta.onClick ? ( + + ) : null)}
); } @@ -40,18 +54,61 @@ export function EmptyShipments({ onCreate }: { onCreate?: () => void }) { return (