From 1eaffa7ba0f46db341a301e16fa3ffb633f21d0c Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 23 Jun 2026 14:25:10 +0300 Subject: [PATCH] feat: ( passenger ) wire IAM global guard, org seeder, and backoffice auth --- apps/edr-passenger-api/src/app.module.ts | 33 ++-- .../src/common/passenger-guards.ts | 14 ++ .../src/common/passenger-permission.guard.ts | 30 ++++ .../src/common/passenger-permission.util.ts | 74 ++++++++ .../src/modules/audit/audit.controller.ts | 7 +- .../src/modules/auth/auth.controller.ts | 3 + .../currencies/currencies.controller.ts | 19 +- .../src/modules/fraud/fraud.controller.ts | 13 +- .../notifications/notifications.controller.ts | 16 +- .../modules/payments/payments.controller.ts | 20 +-- .../src/modules/reports/reports.controller.ts | 10 +- .../src/seed/edr-passenger-org.seeder.ts | 165 ++++++++++++++++++ .../src/seed/edr-passenger.seed.ts | 47 +++++ .../seed/passenger-permissions.registry.ts | 121 +++++++++++++ .../src/seed/passenger-staff-users.seeder.ts | 106 +++++++++++ apps/edr-passenger-api/tsconfig.json | 2 + .../backoffice/src/lib/auth-store.ts | 77 ++++---- 17 files changed, 658 insertions(+), 99 deletions(-) create mode 100644 apps/edr-passenger-api/src/common/passenger-guards.ts create mode 100644 apps/edr-passenger-api/src/common/passenger-permission.guard.ts create mode 100644 apps/edr-passenger-api/src/common/passenger-permission.util.ts create mode 100644 apps/edr-passenger-api/src/seed/edr-passenger-org.seeder.ts create mode 100644 apps/edr-passenger-api/src/seed/edr-passenger.seed.ts create mode 100644 apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts create mode 100644 apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 742314f56..1c659d117 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -8,10 +8,15 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; -// Subpath import: the @tria-plc/iamapi-common barrel does not resolve under moduleResolution:"Node". -// Aliased to avoid clashing with the app's existing custom ./common/iam.module (remote IamGuard). import { IamModule as TriaIamModule } from '@tria-plc/iamapi-common/iam.module'; import { DataSeeder } from '@tria-plc/iamapi-common/db/seed/seeder'; +import { SharedAuthModule } from '@tria-plc/api-common/modules/auth/shared-auth.module'; +import { + EDR_PASSENGER_APPLICATION, + EDR_PASSENGER_PERMISSIONS, +} from './seed/edr-passenger.seed'; +import { EdrPassengerOrgSeeder } from './seed/edr-passenger-org.seeder'; +import { PassengerStaffUsersSeeder } from './seed/passenger-staff-users.seeder'; import { PrismaModule } from './common/prisma.module'; import { AuditModule } from './common/audit.module'; import { I18nModule } from './common/i18n/i18n.module'; @@ -77,7 +82,11 @@ import { CurrenciesModule } from './modules/currencies/currencies.module'; useFactory: (config: ConfigService): TypeOrmModuleOptions => config.get('iamDatabase')!, }), - TriaIamModule.forRoot(), + TriaIamModule.forRoot({ + applications: [EDR_PASSENGER_APPLICATION], + permissions: EDR_PASSENGER_PERMISSIONS, + }), + SharedAuthModule, PrismaModule, AuditModule, I18nModule, @@ -108,21 +117,25 @@ import { CurrenciesModule } from './modules/currencies/currencies.module'; AuditModuleFeature, CurrenciesModule, ], + providers: [ + EdrPassengerOrgSeeder, + PassengerStaffUsersSeeder, + ], }) export class AppModule implements OnApplicationBootstrap { constructor( - private readonly seeder: DataSeeder, - // private readonly edrOrgSeeder: EdrOrgSeeder, - // private readonly demoUsersSeeder: DemoUsersSeeder, - ) { } + private readonly seeder: DataSeeder, + private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder, + private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder, + ) {} async onApplicationBootstrap() { try { await this.seeder.run(); } catch (err) { - console.error('[DataSeeder] Seed failed (non-fatal during IAM migration phase):', (err as Error).message); + console.error('[DataSeeder] Seed failed (non-fatal):', (err as Error).message); } - // await this.edrOrgSeeder.run(); - // await this.demoUsersSeeder.run(); + await this.edrPassengerOrgSeeder.run(); + await this.passengerStaffUsersSeeder.run(); } } diff --git a/apps/edr-passenger-api/src/common/passenger-guards.ts b/apps/edr-passenger-api/src/common/passenger-guards.ts new file mode 100644 index 000000000..cadd56889 --- /dev/null +++ b/apps/edr-passenger-api/src/common/passenger-guards.ts @@ -0,0 +1,14 @@ +import { applyDecorators, UseGuards } from '@nestjs/common'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { PassengerPermissionGuard } from './passenger-permission.guard'; +import { PASSENGER_PERMS } from '../seed/passenger-permissions.registry'; + +export const PassengerStaff = (permission: string | string[]) => + applyDecorators( + UseGuards( + JwtGuard, + PassengerPermissionGuard(Array.isArray(permission) ? permission : [permission]), + ), + ); + +export const PassengerAdmin = () => PassengerStaff(PASSENGER_PERMS.admin); diff --git a/apps/edr-passenger-api/src/common/passenger-permission.guard.ts b/apps/edr-passenger-api/src/common/passenger-permission.guard.ts new file mode 100644 index 000000000..a93b2060b --- /dev/null +++ b/apps/edr-passenger-api/src/common/passenger-permission.guard.ts @@ -0,0 +1,30 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + Type, + UnauthorizedException, +} from '@nestjs/common'; +import { hasPassengerPermission } from './passenger-permission.util'; + +export function PassengerPermissionGuard(permissions: string[]): Type { + @Injectable() + class PassengerPermissionsGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ user?: any }>(); + const user = request.user; + + if (!permissions?.length) return true; + if (!user) throw new UnauthorizedException('Authentication required'); + + if (permissions.some((p) => hasPassengerPermission(user, p))) return true; + + throw new ForbiddenException( + `Missing permission. Required one of: ${permissions.join(', ')}`, + ); + } + } + + return PassengerPermissionsGuard; +} diff --git a/apps/edr-passenger-api/src/common/passenger-permission.util.ts b/apps/edr-passenger-api/src/common/passenger-permission.util.ts new file mode 100644 index 000000000..62df74603 --- /dev/null +++ b/apps/edr-passenger-api/src/common/passenger-permission.util.ts @@ -0,0 +1,74 @@ +import { ForbiddenException } from '@nestjs/common'; + +const SUPER_ADMIN_ROLE = 'super_admin'; +const ORGANIZATION_ADMIN_ROLE = 'organization_admin'; + +type PermissionLike = { key?: string }; +type MeLikeUser = { + roles?: { key?: string }[]; + permissions?: PermissionLike[]; + employee?: + | { position?: { permissions?: PermissionLike[] }; delegatedPositions?: { permissions?: PermissionLike[] }[] } + | { positions?: { permissions?: PermissionLike[] }[] }[] + | null; +}; + +export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean { + return user?.roles?.some((r) => r.key === SUPER_ADMIN_ROLE) ?? false; +} + +export function isOrganizationAdmin(user: MeLikeUser | null | undefined): boolean { + return user?.roles?.some((r) => r.key === ORGANIZATION_ADMIN_ROLE) ?? false; +} + +export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] { + if (!user) return []; + + const keys = new Set(); + + for (const p of user.permissions ?? []) { + if (p.key) keys.add(p.key); + } + + const employee = user.employee; + if (!employee) return [...keys]; + + if (Array.isArray(employee)) { + for (const emp of employee) { + for (const pos of emp.positions ?? []) { + for (const p of pos.permissions ?? []) { + if (p.key) keys.add(p.key); + } + } + } + return [...keys]; + } + + for (const p of employee.position?.permissions ?? []) { + if (p.key) keys.add(p.key); + } + for (const delegated of employee.delegatedPositions ?? []) { + for (const p of delegated.permissions ?? []) { + if (p.key) keys.add(p.key); + } + } + + return [...keys]; +} + +export function hasPassengerPermission( + user: MeLikeUser | null | undefined, + permissionKey: string, +): boolean { + if (!user) return false; + if (isSuperAdmin(user) || isOrganizationAdmin(user)) return true; + return collectPermissionKeys(user).includes(permissionKey); +} + +export function assertPassengerPermission( + user: MeLikeUser | null | undefined, + permissionKey: string, +): void { + if (hasPassengerPermission(user, permissionKey)) return; + throw new ForbiddenException(`Missing permission: ${permissionKey}`); +} diff --git a/apps/edr-passenger-api/src/modules/audit/audit.controller.ts b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts index 37bc89855..1202e4d45 100644 --- a/apps/edr-passenger-api/src/modules/audit/audit.controller.ts +++ b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts @@ -1,11 +1,12 @@ -import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { Controller, Get, Param, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { AuditService } from '../../common/audit.service'; -import { IamGuard } from '../../common/iam-adapter'; +import { PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Audit') @Controller('audit') -@UseGuards(IamGuard) +@PassengerStaff([PASSENGER_PERMS.audit.view, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') export class AuditController { constructor(private auditService: AuditService) {} diff --git a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts index 01b7f686a..8d3e0e4ca 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Request, UnauthorizedException } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { PassengerAuthService } from './passenger-auth.service'; import { RegisterDto, LoginDto } from './auth.dto'; import { JwtGuard } from '../../common/jwt.guard'; @@ -10,6 +11,7 @@ export class AuthController { constructor(private passengerAuthService: PassengerAuthService) {} @Post('register') + @IsPublic() @ApiOperation({ summary: 'Register new passenger account' }) @ApiResponse({ status: 201, description: 'Account created. Returns token + user.' }) @ApiResponse({ status: 409, description: 'Email or phone already registered' }) @@ -19,6 +21,7 @@ export class AuthController { } @Post('login') + @IsPublic() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Login with email and password' }) @ApiResponse({ status: 200, description: 'Login successful. Returns token + passengerId.' }) diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts index 29209af50..87dee3ec8 100644 --- a/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts @@ -1,10 +1,9 @@ -import { Controller, Get, Post, Patch, Delete, Body, Param, HttpCode, UseGuards } from '@nestjs/common'; +import { Controller, Get, Post, Patch, Delete, Body, Param, HttpCode } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { CurrenciesService } from './currencies.service'; import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto'; -import { IamGuard } from '../../common/iam-adapter'; -import { Roles } from '../../common/roles.decorator'; -import { RolesGuard } from '../../common/roles.guard'; +import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Currencies') @Controller('currencies') @@ -17,8 +16,7 @@ export class CurrenciesController { } @Post() - @UseGuards(IamGuard, RolesGuard) - @Roles('ADMIN') + @PassengerStaff(PASSENGER_PERMS.currencies.manage) @ApiBearerAuth('IAM-auth') @HttpCode(201) createCurrency(@Body() dto: CreateCurrencyDto) { @@ -26,24 +24,21 @@ export class CurrenciesController { } @Patch(':id') - @UseGuards(IamGuard, RolesGuard) - @Roles('ADMIN') + @PassengerStaff(PASSENGER_PERMS.currencies.manage) @ApiBearerAuth('IAM-auth') updateCurrency(@Param('id') id: string, @Body() dto: UpdateCurrencyDto) { return this.currenciesService.updateCurrency(id, dto); } @Delete(':id') - @UseGuards(IamGuard, RolesGuard) - @Roles('ADMIN') + @PassengerAdmin() @ApiBearerAuth('IAM-auth') deleteCurrency(@Param('id') id: string) { return this.currenciesService.deleteCurrency(id); } @Post('sync-rates') - @UseGuards(IamGuard, RolesGuard) - @Roles('ADMIN') + @PassengerStaff(PASSENGER_PERMS.currencies.manage) @ApiBearerAuth('IAM-auth') @HttpCode(200) syncRates() { diff --git a/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts b/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts index a01524042..c53d3a5fb 100644 --- a/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts +++ b/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts @@ -1,14 +1,12 @@ -import { Controller, Get, Post, Body, Query, UseGuards, Logger } from '@nestjs/common'; +import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { FraudService, FraudRuleConfig } from './fraud.service'; -// IAM auth: validate the IAM session token via @tria-plc/api-common's DB-backed JwtGuard. -import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Fraud Detection') @Controller('fraud') -// TODO(iam-authz): restrict per route via @UseGuards(PermissionGuard([...])) once the IAM -// role→permission mapping (EIamPermissionKey) is confirmed. For now: authenticated IAM users only. -@UseGuards(IamJwtGuard) +@PassengerStaff([PASSENGER_PERMS.fraud.view, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') export class FraudController { private readonly logger = new Logger(FraudController.name); @@ -43,6 +41,7 @@ export class FraudController { * Create or update fraud rule */ @Post('rules') + @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) @ApiOperation({ summary: 'Create or update fraud rule' }) async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) { const rule = await this.fraudService.upsertRule(body.type, body.config); @@ -53,6 +52,7 @@ export class FraudController { * Block user temporarily */ @Post('actions/block') + @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) @ApiOperation({ summary: 'Block user temporarily' }) async blockUser(@Body() body: { iamUserId: string; durationMinutes: number }) { await this.fraudService.blockUserTemporarily(body.iamUserId, body.durationMinutes); @@ -63,6 +63,7 @@ export class FraudController { * Unblock user */ @Post('actions/unblock') + @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) @ApiOperation({ summary: 'Unblock user' }) async unblockUser(@Body() body: { iamUserId: string }) { await this.fraudService.unblockUser(body.iamUserId); diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts index 95fc0c640..f27bd8ecd 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.controller.ts @@ -2,9 +2,8 @@ import { Controller, Get, Param, Patch, Post, Body, UseGuards } from '@nestjs/co import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody } from '@nestjs/swagger'; import { NotificationsService } from './notifications.service'; import { JwtGuard } from '../../common/jwt.guard'; -import { IamGuard } from '../../common/iam-adapter'; -import { Roles } from '../../common/roles.decorator'; -import { RolesGuard } from '../../common/roles.guard'; +import { PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; import { TestNotificationDto } from './notifications.dto'; import { EmailClientService } from './email-client.service'; import { SmsClientService } from './sms-client.service'; @@ -41,8 +40,7 @@ export class NotificationsController { } @Post('send/email') - @UseGuards(IamGuard, RolesGuard) - @Roles('ADMIN', 'STAFF') + @PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin]) @ApiOperation({ summary: 'Send a direct email via the email microservice' }) @ApiBody({ type: SendEmail }) sendEmail(@Body() dto: SendEmail) { @@ -50,8 +48,7 @@ export class NotificationsController { } @Post('send/sms') - @UseGuards(IamGuard, RolesGuard) - @Roles('ADMIN', 'STAFF') + @PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin]) @ApiOperation({ summary: 'Send a direct SMS via the SMS microservice' }) @ApiBody({ type: SingleMessageDto }) sendSms(@Body() dto: SingleMessageDto) { @@ -59,8 +56,7 @@ export class NotificationsController { } @Post('send/sms/bulk') - @UseGuards(IamGuard, RolesGuard) - @Roles('ADMIN', 'STAFF') + @PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin]) @ApiOperation({ summary: 'Send bulk SMS messages via the SMS microservice' }) @ApiBody({ type: BulkMessagesDto }) sendBulkSms(@Body() dto: BulkMessagesDto) { @@ -68,8 +64,6 @@ export class NotificationsController { } @Post('test') - // TODO(iam-authz): restrict to admin/staff via IAM PermissionGuard once role→permission mapping - // is confirmed. Currently protected by the class-level JwtGuard only. @ApiOperation({ summary: 'Test notification delivery (Admin only)' }) async testNotification(@Body() dto: TestNotificationDto) { return this.service.send( diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index a87a00118..4cb79a89f 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -28,9 +28,8 @@ import { PaymentMethodTypeEnum, PaymentPlatformDto, } from "./payments.dto"; -import { JwtGuard } from "../../common/jwt.guard"; -import { RolesGuard } from "../../common/roles.guard"; -import { Roles } from "../../common/roles.decorator"; +import { PassengerStaff } from "../../common/passenger-guards"; +import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @ApiTags("Payment") @Controller("payments") @@ -38,9 +37,8 @@ export class PaymentsController { constructor(private service: PaymentsService) {} @Get("all") - @UseGuards(JwtGuard, RolesGuard) - @Roles('ADMIN', 'SUPERVISOR', 'STAFF') - @ApiBearerAuth("JWT-auth") + @PassengerStaff([PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin]) + @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Get all payments with filters (staff/admin only)" }) @ApiQuery({ name: "search", required: false }) @ApiQuery({ name: "status", required: false }) @@ -101,18 +99,16 @@ export class PaymentsController { } @Post("refund") - @UseGuards(JwtGuard, RolesGuard) - @Roles('ADMIN', 'STAFF', 'AGENT') - @ApiBearerAuth("JWT-auth") + @PassengerStaff([PASSENGER_PERMS.payments.refund, PASSENGER_PERMS.admin]) + @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Refund a confirmed booking (staff/agent only)" }) refund(@Body() dto: RefundDto) { return this.service.refund(dto); } @Post("methods") - @UseGuards(JwtGuard, RolesGuard) - @Roles('ADMIN', 'STAFF') - @ApiBearerAuth("JWT-auth") + @PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin]) + @ApiBearerAuth("IAM-auth") @ApiOperation({ summary: "Add a payment system to the platform catalog (admin only)", }) diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 10d1dece7..0c0d02ea4 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -1,15 +1,13 @@ -import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ReportsService } from './reports.service'; import { GenerateReportDto } from './reports.dto'; -// IAM auth: validate the IAM session token via @tria-plc/api-common's DB-backed JwtGuard. -import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Reports') @Controller('reports') -// TODO(iam-authz): restrict per route via @UseGuards(PermissionGuard([...])) once the IAM -// role→permission mapping (EIamPermissionKey) is confirmed. For now: authenticated IAM users only. -@UseGuards(IamJwtGuard) +@PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') export class ReportsController { constructor(private service: ReportsService) {} diff --git a/apps/edr-passenger-api/src/seed/edr-passenger-org.seeder.ts b/apps/edr-passenger-api/src/seed/edr-passenger-org.seeder.ts new file mode 100644 index 000000000..51995b2d9 --- /dev/null +++ b/apps/edr-passenger-api/src/seed/edr-passenger-org.seeder.ts @@ -0,0 +1,165 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + Application, + Organization, + OrganizationConfiguration, + Permission, + Role, + RolePermission, +} from '@tria-plc/iamapi-common'; +import { DataSource, EntityManager, In } from 'typeorm'; +import { ERoleKey } from '@tria-plc/api-common/utils/enums/seed.enum'; +import { + PASSENGER_PERMISSIONS, + PASSENGER_PERMISSION_KEYS, +} from './passenger-permissions.registry'; +import { EDR_PASSENGER_APPLICATION, EDR_PASSENGER_ROLES, type PassengerSeedRole } from './edr-passenger.seed'; + +const EDR_ORG_KEY = 'edr'; +const EDR_ORG_NAME = { am: 'EDR', en: 'EDR' }; +const SEED_FLAG = 'SEED_EDR_PASSENGER_ORG'; + +type SeedOrganization = { id: string; key: string }; + +@Injectable() +export class EdrPassengerOrgSeeder { + private readonly logger = new Logger(EdrPassengerOrgSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') { + this.logger.log(`Skipping passenger org seed because ${SEED_FLAG} is not enabled`); + return; + } + + await this.dataSource.transaction(async (manager) => { + await this.ensureApplication(manager); + await this.ensurePermissions(manager); + const organization = await this.ensureOrganization(manager); + await this.ensureOrganizationConfiguration(manager, organization.id); + await this.ensureRoles(manager, EDR_PASSENGER_ROLES); + await this.ensureRolePermissions(manager, EDR_PASSENGER_ROLES); + await this.ensureSuperAdminPermissions(manager); + }); + + this.logger.log(`Ensured EDR passenger organization seed for '${EDR_ORG_KEY}'`); + } + + private async ensureApplication(manager: EntityManager) { + await manager.getRepository(Application).upsert( + { + id: EDR_PASSENGER_APPLICATION.id, + key: EDR_PASSENGER_APPLICATION.key, + name: EDR_PASSENGER_APPLICATION.name, + }, + { conflictPaths: { key: true } }, + ); + this.logger.log(`Ensured application '${EDR_PASSENGER_APPLICATION.key}'`); + } + + private async ensurePermissions(manager: EntityManager) { + await manager.getRepository(Permission).upsert( + PASSENGER_PERMISSIONS.map((p) => ({ + id: p.id, + key: p.key, + name: p.name, + applicationId: EDR_PASSENGER_APPLICATION.id, + })), + { conflictPaths: { key: true } }, + ); + this.logger.log(`Ensured ${PASSENGER_PERMISSIONS.length} passenger permissions`); + } + + private async ensureOrganization(manager: EntityManager): Promise { + const repo = manager.getRepository(Organization); + let org = await repo.findOne({ where: { key: EDR_ORG_KEY }, select: { id: true, key: true } }); + + if (!org) { + const result = await repo.insert({ + key: EDR_ORG_KEY, + name: EDR_ORG_NAME, + isGovernmentOrganization: true, + }); + this.logger.log(`Seeded EDR passenger organization '${EDR_ORG_KEY}'`); + return { id: result.identifiers[0]?.id as string, key: EDR_ORG_KEY }; + } + + this.logger.log(`Ensured EDR passenger organization '${EDR_ORG_KEY}'`); + return { id: org.id as string, key: EDR_ORG_KEY }; + } + + private async ensureOrganizationConfiguration(manager: EntityManager, organizationId: string) { + await manager.getRepository(OrganizationConfiguration).upsert( + { organizationId, canCreateBranchByItself: true, canStartReceivingRecord: true }, + { conflictPaths: { organizationId: true } }, + ); + this.logger.log(`Ensured organization configuration for '${EDR_ORG_KEY}'`); + } + + private async ensureRoles(manager: EntityManager, seedRoles: PassengerSeedRole[]) { + await manager.getRepository(Role).upsert( + seedRoles.map(({ key, name }) => ({ key, name })), + { conflictPaths: { key: true } }, + ); + this.logger.log(`Ensured passenger roles: ${seedRoles.map((r) => r.key).join(', ')}`); + } + + private async ensureRolePermissions(manager: EntityManager, seedRoles: PassengerSeedRole[]) { + const allPermissionKeys = [...new Set(seedRoles.flatMap((r) => r.permissionKeys))]; + if (!allPermissionKeys.length) return; + + const roles = await manager.getRepository(Role).find({ + where: { key: In(seedRoles.map((r) => r.key)) }, + select: { id: true, key: true }, + }); + const permissions = await manager.getRepository(Permission).find({ + where: { key: In(allPermissionKeys) }, + select: { id: true, key: true }, + }); + + const roleByKey = new Map(roles.map((r) => [r.key, r])); + const permByKey = new Map(permissions.map((p) => [p.key, p])); + + const links = seedRoles.flatMap((seedRole) => { + const role = roleByKey.get(seedRole.key); + if (!role) throw new Error(`missing_role:${seedRole.key}`); + + return seedRole.permissionKeys.map((key) => { + const perm = permByKey.get(key); + if (!perm) throw new Error(`missing_permission:${key}`); + return { roleId: role.id, permissionId: perm.id }; + }); + }); + + await manager.getRepository(RolePermission).upsert(links, { + conflictPaths: { roleId: true, permissionId: true }, + }); + this.logger.log(`Ensured ${links.length} passenger role-permission links`); + } + + private async ensureSuperAdminPermissions(manager: EntityManager) { + const role = await manager.getRepository(Role).findOne({ + where: { key: ERoleKey.SUPER_ADMIN }, + select: { id: true, key: true }, + }); + + if (!role) { + this.logger.warn(`Role ${ERoleKey.SUPER_ADMIN} not found; skipping super_admin permission links`); + return; + } + + const permissions = await manager.getRepository(Permission).find({ + where: { key: In(PASSENGER_PERMISSION_KEYS) }, + select: { id: true, key: true }, + }); + + if (!permissions.length) return; + + await manager.getRepository(RolePermission).upsert( + permissions.map((p) => ({ roleId: role.id, permissionId: p.id })), + { conflictPaths: { roleId: true, permissionId: true } }, + ); + this.logger.log(`Ensured ${permissions.length} passenger permissions on super_admin`); + } +} diff --git a/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts b/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts new file mode 100644 index 000000000..8413cadb2 --- /dev/null +++ b/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts @@ -0,0 +1,47 @@ +import { + PASSENGER_PERMISSIONS, + PASSENGER_PERMISSION_KEYS, + ROLE_PERMISSION_PRESETS, +} from './passenger-permissions.registry'; + +export type PassengerSeedRole = { + key: string; + name: { en: string }; + permissionKeys: string[]; +}; + +export const EDR_PASSENGER_APPLICATION = { + id: 'd2000001-0001-4000-8000-000000000001', + key: 'edr_passenger_app', + name: { + am: 'EDR Passenger App', + en: 'EDR Passenger App', + }, +} as const; + +export const EDR_PASSENGER_PERMISSIONS = [...PASSENGER_PERMISSIONS]; + +export { PASSENGER_PERMISSION_KEYS } from './passenger-permissions.registry'; + +export const EDR_PASSENGER_ROLES: PassengerSeedRole[] = [ + { + key: 'edr_passenger_backoffice_admin', + name: { en: 'EDR Passenger Backoffice Admin' }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.backofficeAdmin], + }, + { + key: 'edr_passenger_backoffice_staff', + name: { en: 'EDR Passenger Backoffice Staff' }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.backofficeStaff], + }, + { + key: 'edr_passenger_agent', + name: { en: 'EDR Passenger Agent' }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.agent], + }, + { + key: 'edr_passenger_finance', + name: { en: 'EDR Passenger Finance' }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.finance], + }, +]; diff --git a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts new file mode 100644 index 000000000..9b06c812b --- /dev/null +++ b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts @@ -0,0 +1,121 @@ +const APP_KEY = 'edr_passenger_app'; + +export type PassengerPermissionSeed = { + id: string; + key: string; + name: { am: string; en: string }; + applicationKey: string; +}; + +const perm = (id: string, key: string, en: string): PassengerPermissionSeed => ({ + id, + key, + name: { am: en, en }, + applicationKey: APP_KEY, +}); + +export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [ + perm('c1000001-0001-4000-8000-000000000001', 'edr_passenger_app:bookings:view', 'View bookings'), + perm('c1000001-0001-4000-8000-000000000002', 'edr_passenger_app:bookings:manage', 'Manage bookings'), + perm('c1000001-0001-4000-8000-000000000003', 'edr_passenger_app:bookings:cancel', 'Cancel bookings'), + perm('c1000001-0001-4000-8000-000000000004', 'edr_passenger_app:passengers:view', 'View passengers'), + perm('c1000001-0001-4000-8000-000000000005', 'edr_passenger_app:passengers:manage', 'Manage passengers'), + perm('c1000001-0001-4000-8000-000000000006', 'edr_passenger_app:tickets:view', 'View tickets'), + perm('c1000001-0001-4000-8000-000000000007', 'edr_passenger_app:tickets:manage', 'Manage tickets'), + perm('c1000001-0001-4000-8000-000000000008', 'edr_passenger_app:payments:view_all', 'View all payments'), + perm('c1000001-0001-4000-8000-000000000009', 'edr_passenger_app:payments:refund', 'Refund payments'), + perm('c1000001-0001-4000-8000-00000000000a', 'edr_passenger_app:payments:manage_methods', 'Manage payment methods'), + perm('c1000001-0001-4000-8000-00000000000b', 'edr_passenger_app:reports:view', 'View reports'), + perm('c1000001-0001-4000-8000-00000000000c', 'edr_passenger_app:fraud:view', 'View fraud alerts'), + perm('c1000001-0001-4000-8000-00000000000d', 'edr_passenger_app:fraud:manage', 'Manage fraud rules'), + perm('c1000001-0001-4000-8000-00000000000e', 'edr_passenger_app:audit:view', 'View audit logs'), + perm('c1000001-0001-4000-8000-00000000000f', 'edr_passenger_app:agents:view', 'View agents'), + perm('c1000001-0001-4000-8000-000000000010', 'edr_passenger_app:agents:manage', 'Manage agents'), + perm('c1000001-0001-4000-8000-000000000011', 'edr_passenger_app:currencies:manage', 'Manage currencies'), + perm('c1000001-0001-4000-8000-000000000012', 'edr_passenger_app:notifications:send', 'Send notifications'), + perm('c1000001-0001-4000-8000-000000000013', 'edr_passenger_app:dashboard:view', 'View dashboard'), + perm('c1000001-0001-4000-8000-000000000014', 'edr_passenger_app:admin', 'Full admin access'), +]; + +export const PASSENGER_PERMISSION_KEYS = PASSENGER_PERMISSIONS.map((p) => p.key); + +export const PASSENGER_PERMS = { + bookings: { + view: 'edr_passenger_app:bookings:view', + manage: 'edr_passenger_app:bookings:manage', + cancel: 'edr_passenger_app:bookings:cancel', + }, + passengers: { + view: 'edr_passenger_app:passengers:view', + manage: 'edr_passenger_app:passengers:manage', + }, + tickets: { + view: 'edr_passenger_app:tickets:view', + manage: 'edr_passenger_app:tickets:manage', + }, + payments: { + viewAll: 'edr_passenger_app:payments:view_all', + refund: 'edr_passenger_app:payments:refund', + manageMethods: 'edr_passenger_app:payments:manage_methods', + }, + reports: { + view: 'edr_passenger_app:reports:view', + }, + fraud: { + view: 'edr_passenger_app:fraud:view', + manage: 'edr_passenger_app:fraud:manage', + }, + audit: { + view: 'edr_passenger_app:audit:view', + }, + agents: { + view: 'edr_passenger_app:agents:view', + manage: 'edr_passenger_app:agents:manage', + }, + currencies: { + manage: 'edr_passenger_app:currencies:manage', + }, + notifications: { + send: 'edr_passenger_app:notifications:send', + }, + dashboard: { + view: 'edr_passenger_app:dashboard:view', + }, + admin: 'edr_passenger_app:admin', +} as const; + +export const ROLE_PERMISSION_PRESETS = { + backofficeAdmin: [...PASSENGER_PERMISSION_KEYS], + + backofficeStaff: [ + PASSENGER_PERMS.bookings.view, + PASSENGER_PERMS.bookings.manage, + PASSENGER_PERMS.bookings.cancel, + PASSENGER_PERMS.passengers.view, + PASSENGER_PERMS.passengers.manage, + PASSENGER_PERMS.tickets.view, + PASSENGER_PERMS.tickets.manage, + PASSENGER_PERMS.payments.viewAll, + PASSENGER_PERMS.reports.view, + PASSENGER_PERMS.dashboard.view, + PASSENGER_PERMS.notifications.send, + PASSENGER_PERMS.agents.view, + PASSENGER_PERMS.fraud.view, + PASSENGER_PERMS.audit.view, + ], + + agent: [ + PASSENGER_PERMS.bookings.view, + PASSENGER_PERMS.bookings.manage, + PASSENGER_PERMS.passengers.view, + PASSENGER_PERMS.tickets.view, + PASSENGER_PERMS.payments.refund, + ], + + finance: [ + PASSENGER_PERMS.payments.viewAll, + PASSENGER_PERMS.payments.refund, + PASSENGER_PERMS.reports.view, + PASSENGER_PERMS.dashboard.view, + ], +} as const; diff --git a/apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts b/apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts new file mode 100644 index 000000000..b16138ea2 --- /dev/null +++ b/apps/edr-passenger-api/src/seed/passenger-staff-users.seeder.ts @@ -0,0 +1,106 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { hashPassword } from '@tria-plc/api-common/utils/argon'; +import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum'; +import { + Employee, + Organization, + Role, + User, + UserCredential, + UserRole, +} from '@tria-plc/iamapi-common'; +import { DataSource } from 'typeorm'; + +const SEED_FLAG = 'SEED_PASSENGER_STAFF'; +const EDR_ORG_KEY = 'edr'; + +const STAFF_USERS = [ + { email: 'passenger.admin@edr.local', username: 'passenger_admin', roleKey: 'edr_passenger_backoffice_admin' }, + { email: 'passenger.staff@edr.local', username: 'passenger_staff', roleKey: 'edr_passenger_backoffice_staff' }, + { email: 'passenger.agent@edr.local', username: 'passenger_agent', roleKey: 'edr_passenger_agent' }, + { email: 'passenger.finance@edr.local', username: 'passenger_finance', roleKey: 'edr_passenger_finance' }, +] as const; + +@Injectable() +export class PassengerStaffUsersSeeder { + private readonly logger = new Logger(PassengerStaffUsersSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') { + this.logger.log(`Skipping passenger staff seed because ${SEED_FLAG} is not enabled`); + return; + } + + const password = process.env.DEFAULT_PASSWORD?.trim() || '12345678'; + + await this.dataSource.transaction(async (manager) => { + const organization = await manager.getRepository(Organization).findOne({ + where: { key: EDR_ORG_KEY }, + select: { id: true, key: true }, + }); + + if (!organization) throw new Error(`missing_organization:${EDR_ORG_KEY}`); + + const hashedPassword = await hashPassword(password); + + for (const staff of STAFF_USERS) { + const role = await manager.getRepository(Role).findOne({ + where: { key: staff.roleKey }, + select: { id: true, key: true }, + }); + if (!role) throw new Error(`missing_role:${staff.roleKey}`); + + let user = await manager.getRepository(User).findOne({ + where: { email: staff.email }, + select: { id: true, email: true }, + }); + + if (!user) { + user = await manager.getRepository(User).save( + manager.getRepository(User).create({ + email: staff.email, + username: staff.username, + name: { en: staff.username }, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }), + ); + this.logger.log(`Seeded passenger staff user ${staff.email}`); + } + + const credentialExists = await manager.getRepository(UserCredential).exists({ + where: { userId: user.id, isActive: true }, + }); + if (!credentialExists) { + await manager.getRepository(UserCredential).insert({ + userId: user.id, + password: hashedPassword, + isActive: true, + }); + } + + await manager.getRepository(UserRole).upsert( + { userId: user.id, roleId: role.id, organizationId: organization.id }, + { conflictPaths: { userId: true, roleId: true } }, + ); + + const employeeExists = await manager.getRepository(Employee).exists({ + where: { userId: user.id, organizationId: organization.id, isCurrent: true }, + }); + if (!employeeExists) { + await manager.getRepository(Employee).insert({ + userId: user.id, + organizationId: organization.id, + isCurrent: true, + name: { en: staff.username }, + }); + } + } + }); + + this.logger.log('Ensured passenger staff users'); + } +} diff --git a/apps/edr-passenger-api/tsconfig.json b/apps/edr-passenger-api/tsconfig.json index e9fbe1ffe..49158fb15 100644 --- a/apps/edr-passenger-api/tsconfig.json +++ b/apps/edr-passenger-api/tsconfig.json @@ -8,6 +8,8 @@ "incremental": true, "tsBuildInfoFile": "./.tsbuildinfo", "paths": { "@/*": ["./src/*"] }, + "module": "node16", + "moduleResolution": "node16", "strictPropertyInitialization": false, "noUnusedLocals": false, "noUnusedParameters": false diff --git a/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts b/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts index bdbbc6ba8..fcba670b6 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/auth-store.ts @@ -4,9 +4,17 @@ import axios from 'axios'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; +function mapIamRole(roles: { key?: string }[]): 'ADMIN' | 'AGENT' | 'SUPERVISOR' { + const keys = roles.map((r) => r.key ?? ''); + if (keys.some((k) => k.includes('admin') || k === 'super_admin' || k === 'organization_admin')) return 'ADMIN'; + if (keys.some((k) => k.includes('agent'))) return 'AGENT'; + return 'SUPERVISOR'; +} + interface AuthState { user: AdminUser | null; token: string | null; + refreshToken: string | null; isAuthenticated: boolean; login: (email: string, password: string) => Promise; logout: () => void; @@ -17,6 +25,7 @@ interface AuthState { export const useAuthStore = create((set) => ({ user: null, token: null, + refreshToken: null, isAuthenticated: false, initialize: () => { @@ -27,57 +36,47 @@ export const useAuthStore = create((set) => ({ try { const user = JSON.parse(userStr); set({ user, token, isAuthenticated: true }); - } catch (e) { + } catch { localStorage.removeItem('auth_token'); + localStorage.removeItem('auth_refresh_token'); localStorage.removeItem('auth_user'); } } }, login: async (email: string, password: string) => { - try { - console.log('Attempting login to:', `${API_URL}/auth/login`); - const response = await axios.post(`${API_URL}/auth/login`, { email, password }); - console.log('Full response:', response.data); - - // Backend wraps response in { success, data: { token, user }, timestamp } - const responseData = response.data.data || response.data; - - if (!responseData || !responseData.token || !responseData.user) { - console.error('Invalid response structure:', response.data); - throw new Error('Invalid response from server'); - } - - const { token, user: apiUser } = responseData; - - const user: AdminUser = { - id: apiUser.id, - email: apiUser.email, - fullName: apiUser.fullName, - role: apiUser.role, - active: true, - }; - - console.log('Login successful! User:', user); - - localStorage.setItem('auth_token', token); - localStorage.setItem('auth_user', JSON.stringify(user)); - - set({ user, token, isAuthenticated: true }); - } catch (error: any) { - console.error('Login error details:', { - message: error.message, - response: error.response?.data, - status: error.response?.status, - }); - throw error; - } + // Step 1: IAM login — returns token + refreshToken only + const loginRes = await axios.post(`${API_URL}/v1/auth/login`, { email, password }); + const loginData = loginRes.data?.data ?? loginRes.data; + const { token, refreshToken } = loginData; + if (!token) throw new Error('No token received from server'); + + // Step 2: fetch full user info with the token + const meRes = await axios.get(`${API_URL}/v1/auth/me`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const iamUser = meRes.data?.data ?? meRes.data; + + const user: AdminUser = { + id: iamUser.id, + email: iamUser.email, + fullName: iamUser.name?.en ?? iamUser.name?.am ?? iamUser.email, + role: mapIamRole(iamUser.roles ?? []), + active: true, + }; + + localStorage.setItem('auth_token', token); + localStorage.setItem('auth_user', JSON.stringify(user)); + if (refreshToken) localStorage.setItem('auth_refresh_token', refreshToken); + + set({ user, token, refreshToken: refreshToken ?? null, isAuthenticated: true }); }, logout: () => { localStorage.removeItem('auth_token'); + localStorage.removeItem('auth_refresh_token'); localStorage.removeItem('auth_user'); - set({ user: null, token: null, isAuthenticated: false }); + set({ user: null, token: null, refreshToken: null, isAuthenticated: false }); }, setUser: (user: AdminUser, token: string) => {