mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
Merge pull request #425 from Tria-plc/freight/feature/first_mile_invoice
Freight/feature/first mile invoice
This commit is contained in:
@@ -61,4 +61,25 @@ REDIS_PORT=6379
|
|||||||
RABBITMQ_ENABLED=false
|
RABBITMQ_ENABLED=false
|
||||||
RABBITMQ_URL=amqp://localhost:5672
|
RABBITMQ_URL=amqp://localhost:5672
|
||||||
SMS_QUEUE=sms_queue
|
SMS_QUEUE=sms_queue
|
||||||
|
|
||||||
|
# ── VeriFayda 2.0 (eSignet OIDC) identity verification ──────────────────────
|
||||||
|
# Disabled by default; /fayda/verification/start returns 503 until enabled.
|
||||||
|
FAYDA_ENABLED=false
|
||||||
|
FAYDA_CLIENT_ID=
|
||||||
|
FAYDA_AUTHORIZATION_ENDPOINT=
|
||||||
|
FAYDA_TOKEN_ENDPOINT=
|
||||||
|
FAYDA_USERINFO_ENDPOINT=
|
||||||
|
# Base64-encoded RSA private JWK used for the private_key_jwt client assertion
|
||||||
|
FAYDA_PRIVATE_KEY_BASE64=
|
||||||
|
# OAuth redirect_uri for MOBILE clients (must be registered with eSignet)
|
||||||
|
FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete
|
||||||
|
# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
|
||||||
|
FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback
|
||||||
|
CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
|
||||||
|
FAYDA_SCOPE=openid profile email phone address
|
||||||
|
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
|
||||||
|
FAYDA_CLAIMS_LOCALES=en am
|
||||||
|
FAYDA_SESSION_TTL_MINUTES=10
|
||||||
|
EXPIRATION_TIME=15
|
||||||
|
ALGORITHM=RS256
|
||||||
EMAIL_QUEUE=email_queue
|
EMAIL_QUEUE=email_queue
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"handlebars": "^4.7.9",
|
"handlebars": "^4.7.9",
|
||||||
|
"jose": "^5.10.0",
|
||||||
"libphonenumber-js": "^1.13.6",
|
"libphonenumber-js": "^1.13.6",
|
||||||
"minio": "7.1.3",
|
"minio": "7.1.3",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import appConfig from "./config/app.config";
|
|||||||
import databaseConfig from "./config/database.config";
|
import databaseConfig from "./config/database.config";
|
||||||
import telebirrConfig from "./config/telebirr.config";
|
import telebirrConfig from "./config/telebirr.config";
|
||||||
import rabbitmqConfig from "./config/rabbitmq.config";
|
import rabbitmqConfig from "./config/rabbitmq.config";
|
||||||
|
import faydaConfig from "./config/fayda.config";
|
||||||
|
|
||||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||||
import { ContractsModule } from "./modules/contracts/contracts.module";
|
import { ContractsModule } from "./modules/contracts/contracts.module";
|
||||||
@@ -65,6 +66,7 @@ import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
|||||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||||
//New Trains, Wagons, Container and Cargo management modules
|
//New Trains, Wagons, Container and Cargo management modules
|
||||||
import { TrainsModule } from "./modules/trains/trains.module";
|
import { TrainsModule } from "./modules/trains/trains.module";
|
||||||
|
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
||||||
import { WagonsModule } from "./modules/wagons/wagons.module";
|
import { WagonsModule } from "./modules/wagons/wagons.module";
|
||||||
import { ContainersModule } from "./modules/container-management/containers.module";
|
import { ContainersModule } from "./modules/container-management/containers.module";
|
||||||
import { CargoesModule } from "./modules/cargoes/cargoes.module";
|
import { CargoesModule } from "./modules/cargoes/cargoes.module";
|
||||||
@@ -85,7 +87,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
|
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig],
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
@@ -146,6 +148,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
LastMileModule,
|
LastMileModule,
|
||||||
InterchangeDocumentsModule,
|
InterchangeDocumentsModule,
|
||||||
ImportOperationsModule,
|
ImportOperationsModule,
|
||||||
|
VerifaydaModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
EdrOrgSeeder,
|
EdrOrgSeeder,
|
||||||
|
|||||||
126
apps/edr-freight-api/src/config/fayda.config.ts
Normal file
126
apps/edr-freight-api/src/config/fayda.config.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
|
export interface FaydaJwk {
|
||||||
|
kty: 'RSA';
|
||||||
|
use?: string;
|
||||||
|
kid?: string;
|
||||||
|
alg?: string;
|
||||||
|
n: string;
|
||||||
|
e: string;
|
||||||
|
d: string;
|
||||||
|
p?: string;
|
||||||
|
q?: string;
|
||||||
|
dp?: string;
|
||||||
|
dq?: string;
|
||||||
|
qi?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FaydaPlatform = 'WEB' | 'MOBILE';
|
||||||
|
|
||||||
|
export interface FaydaConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
clientId: string;
|
||||||
|
authorizationEndpoint: string;
|
||||||
|
tokenEndpoint: string;
|
||||||
|
userInfoEndpoint: string;
|
||||||
|
/** OAuth redirect_uri sent to eSignet for MOBILE clients. */
|
||||||
|
redirectUri: string;
|
||||||
|
/** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */
|
||||||
|
webRedirectUri: string;
|
||||||
|
privateJwk: FaydaJwk;
|
||||||
|
scope: string;
|
||||||
|
acrValues: string;
|
||||||
|
claimsLocales: string;
|
||||||
|
sessionTtlMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REQUIRED_VARS = [
|
||||||
|
'FAYDA_CLIENT_ID',
|
||||||
|
'FAYDA_AUTHORIZATION_ENDPOINT',
|
||||||
|
'FAYDA_TOKEN_ENDPOINT',
|
||||||
|
'FAYDA_USERINFO_ENDPOINT',
|
||||||
|
'FAYDA_PRIVATE_KEY_BASE64',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function decodePrivateJwk(base64: string): FaydaJwk {
|
||||||
|
let jwk: unknown;
|
||||||
|
try {
|
||||||
|
const json = Buffer.from(base64, 'base64').toString('utf8');
|
||||||
|
jwk = JSON.parse(json);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(
|
||||||
|
`FAYDA_PRIVATE_KEY_BASE64 is not valid Base64-encoded JSON: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!jwk || typeof jwk !== 'object') {
|
||||||
|
throw new Error('FAYDA_PRIVATE_KEY_BASE64 must decode to a JSON object');
|
||||||
|
}
|
||||||
|
const candidate = jwk as Partial<FaydaJwk>;
|
||||||
|
if (candidate.kty !== 'RSA') {
|
||||||
|
throw new Error('FAYDA_PRIVATE_KEY_BASE64 JWK must have kty="RSA"');
|
||||||
|
}
|
||||||
|
if (!candidate.n || !candidate.e || !candidate.d) {
|
||||||
|
throw new Error(
|
||||||
|
'FAYDA_PRIVATE_KEY_BASE64 JWK is missing required RSA private-key fields (n, e, d)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return candidate as FaydaJwk;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default registerAs('fayda', (): FaydaConfig => {
|
||||||
|
const enabled = (process.env.FAYDA_ENABLED ?? 'false').toLowerCase() === 'true';
|
||||||
|
// `profile` covers name/birthdate/gender/picture; `email`, `phone`, `address`
|
||||||
|
// are needed so the matching essential claims aren't rejected as out-of-scope.
|
||||||
|
const scope = process.env.FAYDA_SCOPE ?? 'openid profile email phone address';
|
||||||
|
const acrValues = process.env.FAYDA_ACR_VALUES ?? 'mosip:idp:acr:generated-code';
|
||||||
|
const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am';
|
||||||
|
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
|
||||||
|
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
|
||||||
|
const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri;
|
||||||
|
if (!enabled) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
clientId: process.env.FAYDA_CLIENT_ID ?? '',
|
||||||
|
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT ?? '',
|
||||||
|
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '',
|
||||||
|
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
|
||||||
|
redirectUri,
|
||||||
|
webRedirectUri,
|
||||||
|
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
|
||||||
|
scope,
|
||||||
|
acrValues,
|
||||||
|
claimsLocales,
|
||||||
|
sessionTtlMinutes: Number.isNaN(sessionTtl) || sessionTtl <= 0 ? 10 : sessionTtl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Fayda integration is enabled (FAYDA_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!redirectUri) {
|
||||||
|
throw new Error(
|
||||||
|
'Fayda integration is enabled but the redirect URI is missing: set FAYDA_REDIRECT_URI',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Number.isNaN(sessionTtl) || sessionTtl <= 0) {
|
||||||
|
throw new Error('FAYDA_SESSION_TTL_MINUTES must be a positive integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
clientId: process.env.FAYDA_CLIENT_ID!,
|
||||||
|
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT!,
|
||||||
|
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!,
|
||||||
|
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
|
||||||
|
redirectUri,
|
||||||
|
webRedirectUri,
|
||||||
|
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
|
||||||
|
scope,
|
||||||
|
acrValues,
|
||||||
|
claimsLocales,
|
||||||
|
sessionTtlMinutes: sessionTtl,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -38,7 +38,8 @@ async function bootstrap() {
|
|||||||
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
||||||
});
|
});
|
||||||
|
|
||||||
app.setGlobalPrefix("api");
|
// /callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack endpoint.
|
||||||
|
app.setGlobalPrefix("api", { exclude: ["callback"] });
|
||||||
// enableImplicitConversion is OFF: class-transformer's implicit boolean
|
// enableImplicitConversion is OFF: class-transformer's implicit boolean
|
||||||
// coercion turns any non-empty multipart/form-data string (including the
|
// coercion turns any non-empty multipart/form-data string (including the
|
||||||
// literal "false") into `true`, silently corrupting flags like isHazardous
|
// literal "false") into `true`, silently corrupting flags like isHazardous
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session store for the VeriFayda 2.0 OIDC verification flow (ported from
|
||||||
|
* passenger-api). One row per started verification; `state` is the
|
||||||
|
* single-use CSRF token linking the eSignet redirect back to the session.
|
||||||
|
*/
|
||||||
|
export class AddFaydaVerificationSessions1890000000002 implements MigrationInterface {
|
||||||
|
name = "AddFaydaVerificationSessions1890000000002";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.fayda_verification_sessions (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
state varchar NOT NULL UNIQUE,
|
||||||
|
code_verifier varchar NOT NULL,
|
||||||
|
purpose varchar NOT NULL DEFAULT 'VERIFY',
|
||||||
|
platform varchar NOT NULL DEFAULT 'WEB',
|
||||||
|
save_to_account boolean NOT NULL DEFAULT false,
|
||||||
|
status varchar NOT NULL DEFAULT 'PENDING',
|
||||||
|
error_code varchar,
|
||||||
|
error_description text,
|
||||||
|
iam_user_id uuid,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
completed_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_FAYDA_SESSIONS_EXPIRES_AT"
|
||||||
|
ON freight.fayda_verification_sessions (expires_at)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_FAYDA_SESSIONS_IAM_USER_ID"
|
||||||
|
ON freight.fayda_verification_sessions (iam_user_id)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.fayda_verification_sessions`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track Fayda identity verification on drivers: whether the driver's
|
||||||
|
* identity was verified through VeriFayda and the OIDC subject it was
|
||||||
|
* verified against.
|
||||||
|
*/
|
||||||
|
export class AddDriverFaydaVerification1890000000003 implements MigrationInterface {
|
||||||
|
name = "AddDriverFaydaVerification1890000000003";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.drivers
|
||||||
|
ADD COLUMN IF NOT EXISTS fayda_verified boolean DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS fayda_sub varchar
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.drivers
|
||||||
|
DROP COLUMN IF EXISTS fayda_verified,
|
||||||
|
DROP COLUMN IF EXISTS fayda_sub
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow more than one vehicle per last-mile delivery. Junction table joins
|
||||||
|
* last_mile ⇄ vehicles; existing single vehicle_id values are backfilled as
|
||||||
|
* the first assignment so nothing is lost.
|
||||||
|
*/
|
||||||
|
export class AddLastMileVehicleAssignments1890000000004 implements MigrationInterface {
|
||||||
|
name = "AddLastMileVehicleAssignments1890000000004";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.last_mile_vehicle_assignments (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
last_mile_id uuid NOT NULL REFERENCES freight.last_mile(id) ON DELETE CASCADE,
|
||||||
|
vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
CONSTRAINT "UQ_LAST_MILE_VEHICLE" UNIQUE (last_mile_id, vehicle_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_LM_VEHICLE_ASSIGNMENTS_VEHICLE"
|
||||||
|
ON freight.last_mile_vehicle_assignments (vehicle_id)
|
||||||
|
`);
|
||||||
|
// Backfill: existing single-vehicle assignments become the first row
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.last_mile_vehicle_assignments (last_mile_id, vehicle_id)
|
||||||
|
SELECT id, vehicle_id FROM freight.last_mile
|
||||||
|
WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL
|
||||||
|
ON CONFLICT (last_mile_id, vehicle_id) DO NOTHING
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_assignments`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the driver's gender. Prefilled from the Fayda VERIFY response
|
||||||
|
* (Male/Female) but editable; nullable so existing rows and manual,
|
||||||
|
* non-Fayda driver records stay valid.
|
||||||
|
*/
|
||||||
|
export class AddDriverGender1890000000005 implements MigrationInterface {
|
||||||
|
name = "AddDriverGender1890000000005";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.drivers
|
||||||
|
ADD COLUMN IF NOT EXISTS gender varchar
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.drivers
|
||||||
|
DROP COLUMN IF EXISTS gender
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce one driver record per verified Fayda identity. A unique index on
|
||||||
|
* fayda_sub blocks a second driver from being created against the same Fayda
|
||||||
|
* OIDC subject; NULLs stay distinct so legacy/unverified rows are unaffected.
|
||||||
|
*/
|
||||||
|
export class AddDriverFaydaSubUnique1890000000006 implements MigrationInterface {
|
||||||
|
name = "AddDriverFaydaSubUnique1890000000006";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB"
|
||||||
|
ON freight.drivers (fayda_sub)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make driver uniqueness soft-delete aware. The original table used plain
|
||||||
|
* column UNIQUE constraints (drivers_email_key, etc.) which count soft-deleted
|
||||||
|
* rows, so deleting a driver then re-adding the same email/phone/license/Fayda
|
||||||
|
* identity failed at the DB with a raw 500 — even though the service's own
|
||||||
|
* (deleted_at-excluding) duplicate check saw nothing. Replace them with partial
|
||||||
|
* unique indexes scoped to live rows (deleted_at IS NULL) so uniqueness matches
|
||||||
|
* what the service enforces and freed values become reusable after deletion.
|
||||||
|
*/
|
||||||
|
export class DriverUniquePartialSoftDelete1890000000007 implements MigrationInterface {
|
||||||
|
name = "DriverUniquePartialSoftDelete1890000000007";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Drop the full-table unique constraints from CreateDriversTable...
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.drivers
|
||||||
|
DROP CONSTRAINT IF EXISTS drivers_email_key,
|
||||||
|
DROP CONSTRAINT IF EXISTS drivers_phone_number_key,
|
||||||
|
DROP CONSTRAINT IF EXISTS drivers_license_number_key
|
||||||
|
`);
|
||||||
|
// ...and the plain fayda_sub unique index from 1890000000006.
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB"`);
|
||||||
|
|
||||||
|
// Re-add each as a partial unique index scoped to non-deleted rows.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_EMAIL_ACTIVE"
|
||||||
|
ON freight.drivers (email) WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_PHONE_ACTIVE"
|
||||||
|
ON freight.drivers (phone_number) WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_LICENSE_ACTIVE"
|
||||||
|
ON freight.drivers (license_number) WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB_ACTIVE"
|
||||||
|
ON freight.drivers (fayda_sub) WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_EMAIL_ACTIVE"`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_PHONE_ACTIVE"`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_LICENSE_ACTIVE"`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB_ACTIVE"`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB"
|
||||||
|
ON freight.drivers (fayda_sub)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.drivers
|
||||||
|
ADD CONSTRAINT drivers_email_key UNIQUE (email),
|
||||||
|
ADD CONSTRAINT drivers_phone_number_key UNIQUE (phone_number),
|
||||||
|
ADD CONSTRAINT drivers_license_number_key UNIQUE (license_number)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { CreateDriverDto } from './dto/create-driver.dto';
|
import { CreateDriverDto } from './dto/create-driver.dto';
|
||||||
@@ -13,6 +13,12 @@ export class DriversService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(dto: CreateDriverDto): Promise<Driver> {
|
async create(dto: CreateDriverDto): Promise<Driver> {
|
||||||
|
if (dto.faydaVerified !== true) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Driver identity must be verified with Fayda before saving',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await this.driverRepo.findOne({
|
const existing = await this.driverRepo.findOne({
|
||||||
where: [
|
where: [
|
||||||
{ licenseNumber: dto.licenseNumber },
|
{ licenseNumber: dto.licenseNumber },
|
||||||
@@ -33,6 +39,17 @@ export class DriversService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.faydaSub) {
|
||||||
|
const dupe = await this.driverRepo.findOne({
|
||||||
|
where: { faydaSub: dto.faydaSub },
|
||||||
|
});
|
||||||
|
if (dupe) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'A driver is already registered for this Fayda identity',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const driver = this.driverRepo.create(dto);
|
const driver = this.driverRepo.create(dto);
|
||||||
return this.driverRepo.save(driver);
|
return this.driverRepo.save(driver);
|
||||||
}
|
}
|
||||||
@@ -106,7 +123,25 @@ export class DriversService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.faydaSub && dto.faydaSub !== driver.faydaSub) {
|
||||||
|
const dupe = await this.driverRepo.findOne({
|
||||||
|
where: { faydaSub: dto.faydaSub },
|
||||||
|
});
|
||||||
|
if (dupe) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'A driver is already registered for this Fayda identity',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Object.assign(driver, dto);
|
Object.assign(driver, dto);
|
||||||
|
|
||||||
|
if (driver.faydaVerified !== true) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Driver identity must be verified with Fayda before saving',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.driverRepo.save(driver);
|
return this.driverRepo.save(driver);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray } from 'class-validator';
|
import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray, IsBoolean } from 'class-validator';
|
||||||
import { DriverStatus } from '../entities/driver.entity';
|
import { DriverStatus, DriverGender } from '../entities/driver.entity';
|
||||||
|
|
||||||
export class CreateDriverDto {
|
export class CreateDriverDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -20,6 +20,10 @@ export class CreateDriverDto {
|
|||||||
@IsDateString()
|
@IsDateString()
|
||||||
dateOfBirth!: string;
|
dateOfBirth!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(DriverGender)
|
||||||
|
gender?: DriverGender;
|
||||||
|
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
licenseExpiryDate!: string;
|
licenseExpiryDate!: string;
|
||||||
|
|
||||||
@@ -42,4 +46,12 @@ export class CreateDriverDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
faydaVerified?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
faydaSub?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export enum DriverStatus {
|
|||||||
ON_LEAVE = 'ON_LEAVE',
|
ON_LEAVE = 'ON_LEAVE',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum DriverGender {
|
||||||
|
MALE = 'MALE',
|
||||||
|
FEMALE = 'FEMALE',
|
||||||
|
OTHER = 'OTHER',
|
||||||
|
}
|
||||||
|
|
||||||
@Entity({ name: 'drivers', schema: 'freight' })
|
@Entity({ name: 'drivers', schema: 'freight' })
|
||||||
export class Driver extends BaseEntity {
|
export class Driver extends BaseEntity {
|
||||||
@Column({ name: 'license_number', unique: true, nullable: true })
|
@Column({ name: 'license_number', unique: true, nullable: true })
|
||||||
@@ -28,6 +34,9 @@ export class Driver extends BaseEntity {
|
|||||||
@Column({ name: 'date_of_birth', type: 'date', nullable: true })
|
@Column({ name: 'date_of_birth', type: 'date', nullable: true })
|
||||||
dateOfBirth?: Date;
|
dateOfBirth?: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
gender?: DriverGender | null;
|
||||||
|
|
||||||
@Column({ name: 'license_expiry_date', type: 'date', nullable: true })
|
@Column({ name: 'license_expiry_date', type: 'date', nullable: true })
|
||||||
licenseExpiryDate?: Date;
|
licenseExpiryDate?: Date;
|
||||||
|
|
||||||
@@ -51,4 +60,12 @@ export class Driver extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ type: 'numeric', precision: 3, scale: 2, nullable: true })
|
@Column({ type: 'numeric', precision: 3, scale: 2, nullable: true })
|
||||||
rating?: number | null;
|
rating?: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'fayda_verified', type: 'boolean', default: false, nullable: true })
|
||||||
|
faydaVerified?: boolean;
|
||||||
|
|
||||||
|
/** Fayda OIDC subject the identity was verified against. Unique — one driver
|
||||||
|
* record per verified Fayda identity (NULLs allowed for legacy/unverified). */
|
||||||
|
@Column({ name: 'fayda_sub', type: 'varchar', unique: true, nullable: true })
|
||||||
|
faydaSub?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm';
|
||||||
|
|
||||||
|
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||||
|
import { LastMile } from './last-mile.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per vehicle assigned to a last-mile delivery. A delivery can be
|
||||||
|
* served by several vehicles at once (multi-truck bookings); the legacy
|
||||||
|
* `last_mile.vehicle_id` column keeps pointing at the first assignment for
|
||||||
|
* backward compatibility.
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'last_mile_vehicle_assignments', schema: 'freight' })
|
||||||
|
@Unique(['lastMileId', 'vehicleId'])
|
||||||
|
@Index(['vehicleId'])
|
||||||
|
export class LastMileVehicleAssignment extends BaseEntity {
|
||||||
|
@Column({ name: 'last_mile_id', type: 'uuid' })
|
||||||
|
lastMileId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => LastMile, (lm) => lm.vehicleAssignments, { nullable: false, onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'last_mile_id' })
|
||||||
|
lastMile?: LastMile;
|
||||||
|
|
||||||
|
@Column({ name: 'vehicle_id', type: 'uuid' })
|
||||||
|
vehicleId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Vehicle, { nullable: false, eager: false })
|
||||||
|
@JoinColumn({ name: 'vehicle_id' })
|
||||||
|
vehicle?: Vehicle;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm
|
|||||||
import { Booking } from '../../bookings/entities/booking.entity';
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||||
import { LastMileContainerAllocation } from './last-mile-container-allocation.entity';
|
import { LastMileContainerAllocation } from './last-mile-container-allocation.entity';
|
||||||
|
import { LastMileVehicleAssignment } from './last-mile-vehicle-assignment.entity';
|
||||||
|
|
||||||
export const LAST_MILE_STATUSES = [
|
export const LAST_MILE_STATUSES = [
|
||||||
'PAYMENT_PENDING',
|
'PAYMENT_PENDING',
|
||||||
@@ -57,4 +58,7 @@ export class LastMile extends BaseEntity {
|
|||||||
|
|
||||||
@OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile)
|
@OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile)
|
||||||
containerAllocations?: LastMileContainerAllocation[];
|
containerAllocations?: LastMileContainerAllocation[];
|
||||||
|
|
||||||
|
@OneToMany(() => LastMileVehicleAssignment, (va) => va.lastMile)
|
||||||
|
vehicleAssignments?: LastMileVehicleAssignment[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Column, Entity, Index } from 'typeorm';
|
||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per started Fayda verification. Mirrors the passenger-api Prisma
|
||||||
|
* model `FaydaVerificationSession`, but stored in the freight schema via
|
||||||
|
* TypeORM. `state` is the single-use CSRF token that links the eSignet
|
||||||
|
* redirect back to this session.
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'fayda_verification_sessions', schema: 'freight' })
|
||||||
|
@Index(['expiresAt'])
|
||||||
|
@Index(['iamUserId'])
|
||||||
|
export class FaydaVerificationSession extends BaseEntity {
|
||||||
|
@Column({ name: 'state', unique: true })
|
||||||
|
state!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'code_verifier' })
|
||||||
|
codeVerifier!: string;
|
||||||
|
|
||||||
|
/** VERIFY | LOGIN */
|
||||||
|
@Column({ name: 'purpose', default: 'VERIFY' })
|
||||||
|
purpose!: string;
|
||||||
|
|
||||||
|
/** WEB | MOBILE — recorded for audit */
|
||||||
|
@Column({ name: 'platform', default: 'WEB' })
|
||||||
|
platform!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'save_to_account', type: 'boolean', default: false })
|
||||||
|
saveToAccount!: boolean;
|
||||||
|
|
||||||
|
/** PENDING | COMPLETED | FAILED */
|
||||||
|
@Column({ name: 'status', default: 'PENDING' })
|
||||||
|
status!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'error_code', type: 'varchar', nullable: true })
|
||||||
|
errorCode?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'error_description', type: 'text', nullable: true })
|
||||||
|
errorDescription?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'iam_user_id', type: 'uuid', nullable: true })
|
||||||
|
iamUserId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'expires_at', type: 'timestamptz' })
|
||||||
|
expiresAt!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||||
|
completedAt?: Date | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||||
|
import { VerifaydaCallbackDto } from './verifayda.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain acknowledgement endpoint for the Fayda redirect_uri when it points at
|
||||||
|
* the API instead of the web app (e.g. MOBILE clients or connectivity checks).
|
||||||
|
* Registered at /callback (excluded from the global /api prefix in main.ts).
|
||||||
|
* It does NOT consume the verification session — the client must still call
|
||||||
|
* GET /api/fayda/verification/complete with the echoed code+state.
|
||||||
|
*/
|
||||||
|
@ApiTags('Fayda Verification')
|
||||||
|
@Controller('callback')
|
||||||
|
export class FaydaCallbackController {
|
||||||
|
@Get()
|
||||||
|
@IsPublic()
|
||||||
|
@ApiOperation({ summary: 'Acknowledge a Fayda redirect (returns OK, echoes code/state)' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
schema: { example: { status: 'ok', code: '...', state: '...' } },
|
||||||
|
})
|
||||||
|
ok(@Query() query: VerifaydaCallbackDto) {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
...(query.code ? { code: query.code } : {}),
|
||||||
|
...(query.state ? { state: query.state } : {}),
|
||||||
|
...(query.error ? { error: query.error } : {}),
|
||||||
|
...(query.error_description ? { error_description: query.error_description } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// return res.redirect(url.toString());
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like the IAM JwtGuard, but never rejects the request.
|
||||||
|
*
|
||||||
|
* When a valid IAM bearer token is present, `request.user` is populated with
|
||||||
|
* the package `TCurrentUser`. Missing or invalid tokens continue as guests.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class OptionalJwtGuard extends IamJwtGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
reflector: Reflector,
|
||||||
|
@InjectDataSource() dataSource: DataSource,
|
||||||
|
) {
|
||||||
|
super(reflector, dataSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await super.canActivate(context);
|
||||||
|
} catch {
|
||||||
|
context.switchToHttp().getRequest().user = undefined;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { exportJWK, generateKeyPair, importJWK, jwtVerify, type JWK } from 'jose';
|
||||||
|
import { generateClientAssertion } from './client-assertion.util';
|
||||||
|
|
||||||
|
describe('generateClientAssertion', () => {
|
||||||
|
let privateJwk: JWK;
|
||||||
|
let publicJwk: JWK;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const kp = await generateKeyPair('RS256', { extractable: true });
|
||||||
|
privateJwk = await exportJWK(kp.privateKey);
|
||||||
|
publicJwk = await exportJWK(kp.publicKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a JWT verifiable with the matching public key', async () => {
|
||||||
|
const jwt = await generateClientAssertion({
|
||||||
|
clientId: 'edr-passenger-test',
|
||||||
|
audience: 'https://esignet.example.com/token',
|
||||||
|
privateJwk,
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifier = await importJWK(publicJwk, 'RS256');
|
||||||
|
const { payload, protectedHeader } = await jwtVerify(jwt, verifier, {
|
||||||
|
issuer: 'edr-passenger-test',
|
||||||
|
subject: 'edr-passenger-test',
|
||||||
|
audience: 'https://esignet.example.com/token',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(protectedHeader.alg).toBe('RS256');
|
||||||
|
expect(protectedHeader.typ).toBe('JWT');
|
||||||
|
expect(payload.iss).toBe('edr-passenger-test');
|
||||||
|
expect(payload.sub).toBe('edr-passenger-test');
|
||||||
|
expect(payload.aud).toBe('https://esignet.example.com/token');
|
||||||
|
expect(typeof payload.iat).toBe('number');
|
||||||
|
expect(typeof payload.exp).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults exp to 120 seconds after iat', async () => {
|
||||||
|
const jwt = await generateClientAssertion({
|
||||||
|
clientId: 'c',
|
||||||
|
audience: 'https://a/token',
|
||||||
|
privateJwk,
|
||||||
|
});
|
||||||
|
const verifier = await importJWK(publicJwk, 'RS256');
|
||||||
|
const { payload } = await jwtVerify(jwt, verifier);
|
||||||
|
expect(payload.exp! - payload.iat!).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors a custom expiresIn', async () => {
|
||||||
|
const jwt = await generateClientAssertion({
|
||||||
|
clientId: 'c',
|
||||||
|
audience: 'https://a/token',
|
||||||
|
privateJwk,
|
||||||
|
expiresIn: '5m',
|
||||||
|
});
|
||||||
|
const verifier = await importJWK(publicJwk, 'RS256');
|
||||||
|
const { payload } = await jwtVerify(jwt, verifier);
|
||||||
|
expect(payload.exp! - payload.iat!).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails verification against a wrong audience', async () => {
|
||||||
|
const jwt = await generateClientAssertion({
|
||||||
|
clientId: 'c',
|
||||||
|
audience: 'https://a/token',
|
||||||
|
privateJwk,
|
||||||
|
});
|
||||||
|
const verifier = await importJWK(publicJwk, 'RS256');
|
||||||
|
await expect(
|
||||||
|
jwtVerify(jwt, verifier, { audience: 'https://other/token' }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { SignJWT, importJWK, type JWK } from 'jose';
|
||||||
|
|
||||||
|
export interface GenerateClientAssertionInput {
|
||||||
|
clientId: string;
|
||||||
|
audience: string;
|
||||||
|
privateJwk: JWK;
|
||||||
|
expiresIn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateClientAssertion(
|
||||||
|
input: GenerateClientAssertionInput,
|
||||||
|
): Promise<string> {
|
||||||
|
const privateKey = await importJWK(input.privateJwk, 'RS256');
|
||||||
|
return new SignJWT({})
|
||||||
|
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
|
||||||
|
.setIssuer(input.clientId)
|
||||||
|
.setSubject(input.clientId)
|
||||||
|
.setAudience(input.audience)
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(input.expiresIn ?? '2m')
|
||||||
|
.sign(privateKey);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { createHash } from 'crypto';
|
||||||
|
import {
|
||||||
|
base64Url,
|
||||||
|
generateCodeChallenge,
|
||||||
|
generateCodeVerifier,
|
||||||
|
generateState,
|
||||||
|
} from './pkce.util';
|
||||||
|
|
||||||
|
describe('pkce.util', () => {
|
||||||
|
describe('base64Url', () => {
|
||||||
|
it('strips padding and replaces + and / with - and _', () => {
|
||||||
|
const input = Buffer.from([0xfb, 0xff, 0xbf, 0xfe]);
|
||||||
|
const out = base64Url(input);
|
||||||
|
expect(out).not.toMatch(/[+/=]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateCodeVerifier', () => {
|
||||||
|
it('returns a base64url-safe string', () => {
|
||||||
|
expect(generateCodeVerifier()).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces unique values across calls', () => {
|
||||||
|
const a = generateCodeVerifier();
|
||||||
|
const b = generateCodeVerifier();
|
||||||
|
expect(a).not.toEqual(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces at least 43 characters (RFC 7636 minimum)', () => {
|
||||||
|
expect(generateCodeVerifier().length).toBeGreaterThanOrEqual(43);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateCodeChallenge', () => {
|
||||||
|
it('equals base64url(sha256(verifier))', () => {
|
||||||
|
const verifier = 'fixed-test-verifier';
|
||||||
|
const expected = createHash('sha256')
|
||||||
|
.update(verifier)
|
||||||
|
.digest('base64')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=/g, '');
|
||||||
|
expect(generateCodeChallenge(verifier)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic for the same verifier', () => {
|
||||||
|
const verifier = generateCodeVerifier();
|
||||||
|
expect(generateCodeChallenge(verifier)).toBe(generateCodeChallenge(verifier));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('differs for different verifiers', () => {
|
||||||
|
expect(generateCodeChallenge('a')).not.toBe(generateCodeChallenge('b'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateState', () => {
|
||||||
|
it('returns a base64url-safe string', () => {
|
||||||
|
expect(generateState()).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces unique values across calls', () => {
|
||||||
|
expect(generateState()).not.toEqual(generateState());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createHash, randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
export function base64Url(buffer: Buffer): string {
|
||||||
|
return buffer
|
||||||
|
.toString('base64')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateCodeVerifier(): string {
|
||||||
|
return base64Url(randomBytes(64));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateCodeChallenge(codeVerifier: string): string {
|
||||||
|
return base64Url(createHash('sha256').update(codeVerifier).digest());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateState(): string {
|
||||||
|
return base64Url(randomBytes(32));
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||||
|
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||||
|
import { OptionalJwtGuard } from './optional-jwt.guard';
|
||||||
|
import {
|
||||||
|
CompleteVerificationResultDto,
|
||||||
|
StartVerificationDto,
|
||||||
|
VerifaydaCallbackDto,
|
||||||
|
VerificationStatusDto,
|
||||||
|
} from './verifayda.dto';
|
||||||
|
import { VerifaydaService } from './verifayda.service';
|
||||||
|
|
||||||
|
/** Minimal slices of the Express req we touch (avoids a hard dependency on
|
||||||
|
* `@types/express`, which isn't resolved in this package). */
|
||||||
|
interface RequestWithOptionalUser {
|
||||||
|
user?: TCurrentUser;
|
||||||
|
}
|
||||||
|
interface RequestWithUser {
|
||||||
|
user: TCurrentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Fayda Verification')
|
||||||
|
@Controller('fayda/verification')
|
||||||
|
export class VerifaydaController {
|
||||||
|
constructor(private readonly service: VerifaydaService) {}
|
||||||
|
|
||||||
|
@Post('start')
|
||||||
|
@IsPublic()
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UseGuards(OptionalJwtGuard)
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Start a VeriFayda 2.0 verification session',
|
||||||
|
description: `Creates a verification session and returns the eSignet authorize URL the frontend should send the user to.
|
||||||
|
|
||||||
|
- Works for **logged-in users** and **guests**. If a valid bearer token is present, the verification is tied to that user.
|
||||||
|
- **VERIFY** (default): the user proves their identity and \`/complete\` returns the verified attributes (name, email, phone, dob, gender).
|
||||||
|
- **LOGIN**: \`/complete\` resolves/creates the user and returns a JWT.
|
||||||
|
- The returned \`authorizationUrl\` already carries the PKCE \`code_challenge\`, CSRF \`state\`, requested \`claims\`, and \`code_challenge_method=S256\`. The frontend simply navigates to it (full page or popup).`,
|
||||||
|
})
|
||||||
|
@ApiOkResponse({
|
||||||
|
description: 'Authorize URL the frontend should redirect the user to.',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
authorizationUrl:
|
||||||
|
'https://esignet.example.com/authorize?client_id=...&state=...&code_challenge=...',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
async start(
|
||||||
|
@Body() dto: StartVerificationDto,
|
||||||
|
@Req() req: RequestWithOptionalUser,
|
||||||
|
): Promise<{ authorizationUrl: string }> {
|
||||||
|
const authorizationUrl = await this.service.startVerification({
|
||||||
|
purpose: dto.purpose ?? 'VERIFY',
|
||||||
|
platform: dto.platform ?? 'WEB',
|
||||||
|
userId: req.user?.id,
|
||||||
|
wantsPasswordSetup: dto.wantsPasswordSetup ?? false,
|
||||||
|
});
|
||||||
|
return { authorizationUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('complete')
|
||||||
|
@IsPublic()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Complete a verification (Fayda redirect / client callback lands here)',
|
||||||
|
description: `This is the registered Fayda \`redirect_uri\`. Fayda redirects the browser here with \`?code&state\``,
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: CompleteVerificationResultDto })
|
||||||
|
async complete(
|
||||||
|
@Query() dto: VerifaydaCallbackDto,
|
||||||
|
): Promise<CompleteVerificationResultDto> {
|
||||||
|
return this.service.completeVerification(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('status')
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get the current user's Fayda verification status",
|
||||||
|
description:
|
||||||
|
'Returns whether the authenticated user has linked a verified Fayda identity to their account, when, and the name on file.',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: VerificationStatusDto })
|
||||||
|
async status(
|
||||||
|
@Req() req: RequestWithUser,
|
||||||
|
): Promise<VerificationStatusDto> {
|
||||||
|
return this.service.getVerificationStatus(req.user.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts
Normal file
105
apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class StartVerificationDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ['LOGIN', 'VERIFY'],
|
||||||
|
default: 'VERIFY',
|
||||||
|
description:
|
||||||
|
'Reason for verification. VERIFY returns the verified identity attributes; LOGIN resolves/creates a user and returns a JWT.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['LOGIN', 'VERIFY'])
|
||||||
|
purpose?: 'LOGIN' | 'VERIFY';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ['WEB', 'MOBILE'],
|
||||||
|
default: 'WEB',
|
||||||
|
description:
|
||||||
|
'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB uses FAYDA_WEB_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. Both land on the same /complete endpoint with identical handling.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['WEB', 'MOBILE'])
|
||||||
|
platform?: 'WEB' | 'MOBILE';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
description:
|
||||||
|
'Set to true when the user opts in to full account registration (checkbox). ' +
|
||||||
|
'When true, the /complete response includes a short-lived token and promptPasswordSetup=true ' +
|
||||||
|
'so the frontend can immediately prompt for a password via POST /v1/auth/set-fayda-password.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
wantsPasswordSetup?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CompleteVerificationResultDto {
|
||||||
|
@ApiProperty({ enum: ['LOGIN', 'VERIFY'] })
|
||||||
|
purpose!: 'LOGIN' | 'VERIFY';
|
||||||
|
|
||||||
|
@ApiProperty() verified!: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'JWT. LOGIN: session token for the authenticated user. VERIFY: short-lived token for calling /v1/auth/set-fayda-password.' })
|
||||||
|
token?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
refreshToken?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Authenticated user summary (LOGIN flow only; same shape as /auth/login).',
|
||||||
|
})
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
passengerId?: string;
|
||||||
|
agentId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' })
|
||||||
|
fullName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Verified email from Fayda (VERIFY flow).' })
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Verified phone number from Fayda (VERIFY flow).' })
|
||||||
|
phoneNumber?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Verified date of birth from Fayda, ISO yyyy-MM-dd (VERIFY flow).',
|
||||||
|
})
|
||||||
|
birthdate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' })
|
||||||
|
gender?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' })
|
||||||
|
userDataSaved?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'IAM user ID of the verified identity (VERIFY flow).' })
|
||||||
|
iamUserId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'True when the IAM account has not yet set a password (VERIFY flow).' })
|
||||||
|
requiresPassword?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'True when the user opted in to immediate password setup (wantsPasswordSetup=true at start) ' +
|
||||||
|
'AND they have not yet set a password. Frontend should navigate to the set-password screen.',
|
||||||
|
})
|
||||||
|
promptPasswordSetup?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VerifaydaCallbackDto {
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsString() code?: string;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsString() state?: string;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsString() error?: string;
|
||||||
|
@ApiPropertyOptional() @IsOptional() @IsString() error_description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VerificationStatusDto {
|
||||||
|
@ApiProperty() verified!: boolean;
|
||||||
|
@ApiPropertyOptional() verifiedAt?: Date;
|
||||||
|
@ApiPropertyOptional() fullName?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { BadGatewayException, ConflictException } from '@nestjs/common';
|
||||||
|
|
||||||
|
export class FaydaTokenExchangeException extends BadGatewayException {
|
||||||
|
constructor(message = 'Fayda token exchange failed') {
|
||||||
|
super({ code: 'FAYDA_TOKEN_EXCHANGE_FAILED', message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FaydaUserInfoException extends BadGatewayException {
|
||||||
|
constructor(message = 'Fayda userinfo fetch failed') {
|
||||||
|
super({ code: 'FAYDA_USERINFO_FAILED', message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FaydaIdentityConflictException extends ConflictException {
|
||||||
|
constructor(message = 'This Fayda identity is already linked to another account') {
|
||||||
|
super({ code: 'FAYDA_IDENTITY_CONFLICT', message });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { VerifaydaController } from './verifayda.controller';
|
||||||
|
import { FaydaCallbackController } from './fayda-callback.controller';
|
||||||
|
import { VerifaydaService } from './verifayda.service';
|
||||||
|
import { FaydaVerificationSession } from './entities/fayda-verification-session.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([FaydaVerificationSession])],
|
||||||
|
controllers: [VerifaydaController, FaydaCallbackController],
|
||||||
|
providers: [VerifaydaService],
|
||||||
|
exports: [VerifaydaService],
|
||||||
|
})
|
||||||
|
export class VerifaydaModule {}
|
||||||
597
apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts
Normal file
597
apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts
Normal file
@@ -0,0 +1,597 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
import { generateToken, generateRefreshToken } from '@tria-plc/api-common/utils/token';
|
||||||
|
import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config';
|
||||||
|
import { FaydaVerificationSession } from './entities/fayda-verification-session.entity';
|
||||||
|
import {
|
||||||
|
generateCodeChallenge,
|
||||||
|
generateCodeVerifier,
|
||||||
|
generateState,
|
||||||
|
} from './utils/pkce.util';
|
||||||
|
import { generateClientAssertion } from './utils/client-assertion.util';
|
||||||
|
import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto';
|
||||||
|
import {
|
||||||
|
FaydaTokenExchangeException,
|
||||||
|
FaydaUserInfoException,
|
||||||
|
} from './verifayda.errors';
|
||||||
|
import {
|
||||||
|
FaydaTokenResponse,
|
||||||
|
FaydaUserInfo,
|
||||||
|
NormalizedFaydaUserInfo,
|
||||||
|
VerifaydaPurpose,
|
||||||
|
} from './verifayda.types';
|
||||||
|
|
||||||
|
export interface StartVerificationInput {
|
||||||
|
purpose: VerifaydaPurpose;
|
||||||
|
platform?: FaydaPlatform;
|
||||||
|
userId?: string; // iamUserId of the authenticated user, if any
|
||||||
|
wantsPasswordSetup?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FaydaUserSummary {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of completing a verification. `verified` is always true on success.
|
||||||
|
* LOGIN additionally returns a JWT + user; VERIFY returns the verified identity
|
||||||
|
* attributes (name, email, phone, dob, gender) for the caller to consume.
|
||||||
|
*/
|
||||||
|
export interface CompleteVerificationResult {
|
||||||
|
purpose: VerifaydaPurpose;
|
||||||
|
verified: boolean;
|
||||||
|
token?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
requiresPassword?: boolean;
|
||||||
|
promptPasswordSetup?: boolean;
|
||||||
|
iamUserId?: string;
|
||||||
|
user?: FaydaUserSummary;
|
||||||
|
fullName?: string;
|
||||||
|
email?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
birthdate?: string;
|
||||||
|
gender?: string;
|
||||||
|
userDataSaved?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VerifaydaService {
|
||||||
|
private readonly logger = new Logger(VerifaydaService.name);
|
||||||
|
|
||||||
|
private readonly faydaConfig: FaydaConfig;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
@InjectRepository(FaydaVerificationSession)
|
||||||
|
private readonly sessionRepo: Repository<FaydaVerificationSession>,
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
) {
|
||||||
|
const fayda = this.config.get<FaydaConfig>('fayda');
|
||||||
|
if (!fayda) {
|
||||||
|
throw new Error('Fayda config namespace not registered');
|
||||||
|
}
|
||||||
|
this.faydaConfig = fayda;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// OIDC flow
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
async startVerification(input: StartVerificationInput): Promise<string> {
|
||||||
|
if (!this.faydaConfig.enabled) {
|
||||||
|
throw new ServiceUnavailableException({
|
||||||
|
code: 'FAYDA_DISABLED',
|
||||||
|
message: 'Fayda integration is not enabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = generateState();
|
||||||
|
const codeVerifier = generateCodeVerifier();
|
||||||
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
||||||
|
const expiresAt = new Date(
|
||||||
|
Date.now() + this.faydaConfig.sessionTtlMinutes * 60_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.sessionRepo.save(
|
||||||
|
this.sessionRepo.create({
|
||||||
|
state,
|
||||||
|
codeVerifier,
|
||||||
|
purpose: input.purpose,
|
||||||
|
platform: input.platform ?? 'WEB',
|
||||||
|
saveToAccount: input.wantsPasswordSetup ?? false,
|
||||||
|
iamUserId: input.userId ?? null,
|
||||||
|
expiresAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.buildAuthorizationUrl({
|
||||||
|
state,
|
||||||
|
codeChallenge,
|
||||||
|
redirectUri: this.redirectUriForPlatform(input.platform ?? 'WEB'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WEB clients use `webRedirectUri`; MOBILE uses the base `redirectUri`. */
|
||||||
|
private redirectUriForPlatform(platform?: FaydaPlatform): string {
|
||||||
|
return platform === 'MOBILE'
|
||||||
|
? this.faydaConfig.redirectUri
|
||||||
|
: this.faydaConfig.webRedirectUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeVerification(
|
||||||
|
query: VerifaydaCallbackDto,
|
||||||
|
): Promise<CompleteVerificationResult> {
|
||||||
|
if (query.error) {
|
||||||
|
this.logger.warn(`Fayda callback returned error: ${query.error}`);
|
||||||
|
if (query.state) {
|
||||||
|
await this.markSessionFailed(
|
||||||
|
query.state,
|
||||||
|
query.error,
|
||||||
|
query.error_description,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'FAYDA_AUTH_ERROR',
|
||||||
|
message: query.error,
|
||||||
|
description: query.error_description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query.code || !query.state) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'FAYDA_MISSING_PARAMETERS',
|
||||||
|
message: 'code and state are required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await this.sessionRepo.findOne({
|
||||||
|
where: { state: query.state },
|
||||||
|
});
|
||||||
|
if (!session || session.status !== 'PENDING') {
|
||||||
|
this.logger.warn('Fayda complete with unknown or non-pending state');
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'FAYDA_INVALID_STATE',
|
||||||
|
message: 'Verification session is invalid or already used',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (session.expiresAt.getTime() < Date.now()) {
|
||||||
|
await this.markSessionFailed(query.state, 'session_expired');
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'FAYDA_SESSION_EXPIRED',
|
||||||
|
message: 'Verification session has expired; start again',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokens = await this.exchangeCodeForTokens(
|
||||||
|
query.code,
|
||||||
|
session.codeVerifier,
|
||||||
|
this.redirectUriForPlatform(session.platform as FaydaPlatform),
|
||||||
|
);
|
||||||
|
const userInfo = await this.fetchUserInfo(tokens.access_token);
|
||||||
|
const normalized = this.normalizeUserInfo(userInfo);
|
||||||
|
|
||||||
|
if (!normalized.sub) {
|
||||||
|
throw new FaydaUserInfoException('Fayda userinfo missing required sub');
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: CompleteVerificationResult;
|
||||||
|
if (session.purpose === 'LOGIN') {
|
||||||
|
const { userId } = await this.handleLoginSuccess(normalized);
|
||||||
|
const login = await this.issueLoginToken(userId);
|
||||||
|
result = { purpose: 'LOGIN', verified: true, ...login };
|
||||||
|
} else {
|
||||||
|
// VERIFY — prove identity, save to IAM, return verified attributes + short-lived token.
|
||||||
|
const { iamUserId, userDataSaved } = await this.upsertIamUser(normalized);
|
||||||
|
|
||||||
|
let sessionToken: { token: string; refreshToken: string; requiresPassword: boolean } | undefined;
|
||||||
|
if (iamUserId) {
|
||||||
|
try {
|
||||||
|
sessionToken = await this.createFaydaSession(iamUserId);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Fayda session creation failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
purpose: 'VERIFY',
|
||||||
|
verified: true,
|
||||||
|
fullName: normalized.fullName,
|
||||||
|
email: normalized.email,
|
||||||
|
phoneNumber: normalized.phoneNumber,
|
||||||
|
birthdate: normalized.birthdate,
|
||||||
|
gender: normalized.gender,
|
||||||
|
userDataSaved,
|
||||||
|
iamUserId: iamUserId ?? undefined,
|
||||||
|
token: sessionToken?.token,
|
||||||
|
refreshToken: sessionToken?.refreshToken,
|
||||||
|
requiresPassword: sessionToken?.requiresPassword,
|
||||||
|
promptPasswordSetup: session.saveToAccount && (sessionToken?.requiresPassword ?? false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sessionRepo.update(session.id, {
|
||||||
|
status: 'COMPLETED',
|
||||||
|
completedAt: new Date(),
|
||||||
|
codeVerifier: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Fayda verification completed: purpose=${session.purpose} platform=${session.platform}`,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const reason = this.classifyFailureReason(err);
|
||||||
|
this.logger.error(
|
||||||
|
`Fayda verification failed: reason=${reason} message=${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
await this.markSessionFailed(
|
||||||
|
query.state,
|
||||||
|
reason,
|
||||||
|
(err as Error).message,
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async issueLoginToken(
|
||||||
|
_userId: string,
|
||||||
|
): Promise<{ token: string; user: FaydaUserSummary }> {
|
||||||
|
throw new UnauthorizedException({
|
||||||
|
code: 'FAYDA_LOGIN_MIGRATED_TO_IAM',
|
||||||
|
message: 'Fayda login tokens are issued by the IAM package auth endpoints.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVerificationStatus(iamUserId: string): Promise<VerificationStatusDto> {
|
||||||
|
const rows = await this.dataSource.query<{ verified_by: string | null; updated_at: Date | null; name: { en: string; am: string } | null }[]>(
|
||||||
|
`SELECT verified_by, updated_at, name FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||||
|
[iamUserId],
|
||||||
|
);
|
||||||
|
const iam = rows[0] ?? null;
|
||||||
|
const faydaVerified = iam?.verified_by === 'fayda';
|
||||||
|
const faydaVerifiedAt = faydaVerified && iam?.updated_at ? new Date(iam.updated_at) : undefined;
|
||||||
|
const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined;
|
||||||
|
return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// OIDC internals
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
private buildAuthorizationUrl(args: {
|
||||||
|
state: string;
|
||||||
|
codeChallenge: string;
|
||||||
|
redirectUri: string;
|
||||||
|
}): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: this.faydaConfig.clientId,
|
||||||
|
response_type: 'code',
|
||||||
|
redirect_uri: args.redirectUri,
|
||||||
|
scope: this.faydaConfig.scope,
|
||||||
|
state: args.state,
|
||||||
|
code_challenge: args.codeChallenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
acr_values: this.faydaConfig.acrValues,
|
||||||
|
claims_locales: this.faydaConfig.claimsLocales,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every claim is marked essential so eSignet shows them locked/pre-checked
|
||||||
|
// on the consent screen — the user cannot toggle any off; they either
|
||||||
|
// consent to all of them or the whole flow is cancelled (?error=...).
|
||||||
|
const claims = {
|
||||||
|
userinfo: {
|
||||||
|
name: { essential: true },
|
||||||
|
phone_number: { essential: true },
|
||||||
|
email: { essential: true },
|
||||||
|
birthdate: { essential: true },
|
||||||
|
gender: { essential: true },
|
||||||
|
address: { essential: true },
|
||||||
|
nationality: { essential: true },
|
||||||
|
picture: { essential: true },
|
||||||
|
},
|
||||||
|
id_token: {},
|
||||||
|
};
|
||||||
|
params.set('claims', JSON.stringify(claims));
|
||||||
|
|
||||||
|
return `${this.faydaConfig.authorizationEndpoint}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exchangeCodeForTokens(
|
||||||
|
code: string,
|
||||||
|
codeVerifier: string,
|
||||||
|
redirectUri: string,
|
||||||
|
): Promise<FaydaTokenResponse> {
|
||||||
|
const clientAssertion = await generateClientAssertion({
|
||||||
|
clientId: this.faydaConfig.clientId,
|
||||||
|
audience: this.faydaConfig.tokenEndpoint,
|
||||||
|
privateJwk: this.faydaConfig.privateJwk,
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
client_id: this.faydaConfig.clientId,
|
||||||
|
client_assertion_type:
|
||||||
|
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
||||||
|
client_assertion: clientAssertion,
|
||||||
|
code_verifier: codeVerifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(this.faydaConfig.tokenEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let detail = '';
|
||||||
|
try {
|
||||||
|
detail = await response.text();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
throw new FaydaTokenExchangeException(
|
||||||
|
`Fayda token endpoint returned ${response.status}${detail ? `: ${detail}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as FaydaTokenResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchUserInfo(accessToken: string): Promise<FaydaUserInfo> {
|
||||||
|
const response = await fetch(this.faydaConfig.userInfoEndpoint, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new FaydaUserInfoException(
|
||||||
|
`Fayda userinfo endpoint returned ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') ?? '';
|
||||||
|
const raw = await response.text();
|
||||||
|
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
return JSON.parse(raw) as FaydaUserInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed JWT response — decode payload (signature verification = production TODO)
|
||||||
|
if (raw.split('.').length === 3) {
|
||||||
|
const payloadB64 = raw.split('.')[1];
|
||||||
|
const normalizedB64 = payloadB64.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const json = Buffer.from(normalizedB64, 'base64').toString('utf8');
|
||||||
|
return JSON.parse(json) as FaydaUserInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FaydaUserInfoException(
|
||||||
|
'Unsupported Fayda userinfo response format',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo {
|
||||||
|
const nameEn = raw['name#en'] as string | undefined;
|
||||||
|
const nameAm = raw['name#am'] as string | undefined;
|
||||||
|
const genderEn = raw['gender#en'] as string | undefined;
|
||||||
|
const genderAm = raw['gender#am'] as string | undefined;
|
||||||
|
const addressEn = raw['address#en'] as string | undefined;
|
||||||
|
const addressAm = raw['address#am'] as string | undefined;
|
||||||
|
const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sub: raw.sub,
|
||||||
|
fullName: (raw.name as string | undefined) ?? nameEn ?? nameAm,
|
||||||
|
phoneNumber: rawPhone ? this.standardizePhoneNumber(rawPhone) : undefined,
|
||||||
|
rawPhoneNumber: rawPhone,
|
||||||
|
email: raw.email as string | undefined,
|
||||||
|
gender: genderEn ?? genderAm ?? (raw.gender as string | undefined),
|
||||||
|
birthdate: raw.birthdate as string | undefined,
|
||||||
|
picture: raw.picture as string | undefined,
|
||||||
|
nameEn,
|
||||||
|
nameAm,
|
||||||
|
genderEn,
|
||||||
|
genderAm,
|
||||||
|
addressEn,
|
||||||
|
addressAm,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private standardizePhoneNumber(phone: string): string {
|
||||||
|
const digits = phone.replace(/\D/g, '');
|
||||||
|
if (digits.startsWith('251')) return `+${digits}`;
|
||||||
|
if (digits.startsWith('0')) return `+251${digits.slice(1)}`;
|
||||||
|
return `+${digits}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOGIN via Fayda is handled entirely by the IAM package's own OIDC flow.
|
||||||
|
// This method is kept as a stub so completeVerification() still compiles;
|
||||||
|
// it throws immediately without touching the database.
|
||||||
|
private async handleLoginSuccess(
|
||||||
|
_normalized: NormalizedFaydaUserInfo,
|
||||||
|
): Promise<{ userId: string }> {
|
||||||
|
throw new UnauthorizedException({
|
||||||
|
code: 'FAYDA_LOGIN_MIGRATED_TO_IAM',
|
||||||
|
message: 'Fayda login tokens are issued by the IAM package at /v1/auth/fayda endpoints.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertIamUser(
|
||||||
|
normalized: NormalizedFaydaUserInfo,
|
||||||
|
): Promise<{ iamUserId: string | null; userDataSaved: boolean }> {
|
||||||
|
try {
|
||||||
|
const iamMetadata = {
|
||||||
|
sub: normalized.sub,
|
||||||
|
address: { am: normalized.addressAm ?? '', en: normalized.addressEn ?? '' },
|
||||||
|
email: normalized.email ?? '',
|
||||||
|
gender: { am: normalized.genderAm ?? '', en: normalized.genderEn ?? '' },
|
||||||
|
name: { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' },
|
||||||
|
phoneNumber: normalized.rawPhoneNumber ?? '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 1 — already linked to this Fayda sub; ensure verified_by is set
|
||||||
|
const bySub = await this.dataSource.query<{ id: string }[]>(
|
||||||
|
`SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`,
|
||||||
|
[normalized.sub],
|
||||||
|
);
|
||||||
|
if (bySub.length > 0) {
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE iam.users SET verified_by = 'fayda', updated_at = NOW() WHERE id = $1`,
|
||||||
|
[bySub[0].id],
|
||||||
|
);
|
||||||
|
return { iamUserId: bySub[0].id, userDataSaved: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2 — existing user by phone or email, not yet Fayda-verified
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
if (normalized.phoneNumber) {
|
||||||
|
params.push(normalized.phoneNumber);
|
||||||
|
conditions.push(`phone_number = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (normalized.email) {
|
||||||
|
params.push(normalized.email);
|
||||||
|
conditions.push(`email = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
const byContact = await this.dataSource.query<{ id: string }[]>(
|
||||||
|
`SELECT id FROM iam.users WHERE ${conditions.join(' OR ')} LIMIT 1`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
if (byContact.length > 0) {
|
||||||
|
const existingId = byContact[0].id;
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE iam.users
|
||||||
|
SET metadata = COALESCE(metadata, '{}'::jsonb) || $1::jsonb,
|
||||||
|
verified_by = 'fayda',
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $2`,
|
||||||
|
[JSON.stringify(iamMetadata), existingId],
|
||||||
|
);
|
||||||
|
return { iamUserId: existingId, userDataSaved: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3 — new user
|
||||||
|
const name = { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' };
|
||||||
|
const username = normalized.phoneNumber ?? normalized.email ?? normalized.sub;
|
||||||
|
const inserted = await this.dataSource.query<{ id: string }[]>(
|
||||||
|
`INSERT INTO iam.users (
|
||||||
|
id, name, username, email, phone_number, metadata,
|
||||||
|
user_type, status, is_active, has_set_password,
|
||||||
|
is_phone_number_verified, verified_by,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
gen_random_uuid(), $1::jsonb, $2, $3, $4, $5::jsonb,
|
||||||
|
'individual', 'submitted', true, false,
|
||||||
|
false, 'fayda',
|
||||||
|
NOW(), NOW()
|
||||||
|
) RETURNING id`,
|
||||||
|
[
|
||||||
|
JSON.stringify(name),
|
||||||
|
username,
|
||||||
|
normalized.email ?? null,
|
||||||
|
normalized.phoneNumber ?? null,
|
||||||
|
JSON.stringify(iamMetadata),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return { iamUserId: inserted[0].id, userDataSaved: true };
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Fayda IAM upsert failed: ${(err as Error).message}`);
|
||||||
|
return { iamUserId: null, userDataSaved: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createFaydaSession(
|
||||||
|
iamUserId: string,
|
||||||
|
): Promise<{ token: string; refreshToken: string; requiresPassword: boolean }> {
|
||||||
|
const rows = await this.dataSource.query<{
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: { en: string; am: string } | null;
|
||||||
|
username: string;
|
||||||
|
phone_number: string | null;
|
||||||
|
has_set_password: boolean;
|
||||||
|
status: string;
|
||||||
|
}[]>(
|
||||||
|
`SELECT id, email, name, username, phone_number, has_set_password, status
|
||||||
|
FROM iam.users WHERE id = $1 LIMIT 1`,
|
||||||
|
[iamUserId],
|
||||||
|
);
|
||||||
|
if (!rows.length) throw new Error(`IAM user ${iamUserId} not found`);
|
||||||
|
const u = rows[0];
|
||||||
|
|
||||||
|
const userInfo = {
|
||||||
|
id: u.id,
|
||||||
|
email: u.email ?? '',
|
||||||
|
name: u.name ?? { en: '', am: '' },
|
||||||
|
userType: 'individual',
|
||||||
|
status: u.status,
|
||||||
|
hasSetPassword: u.has_set_password,
|
||||||
|
isPhoneNumberVerified: false,
|
||||||
|
hasFinishedRegistration: false,
|
||||||
|
hasFinishedDMSOnboarding: false,
|
||||||
|
username: u.username,
|
||||||
|
phoneNumber: u.phone_number ?? '',
|
||||||
|
roles: [],
|
||||||
|
permissions: [],
|
||||||
|
employee: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessions = await this.dataSource.query<{ id: string }[]>(
|
||||||
|
`INSERT INTO iam.sessions
|
||||||
|
(id, email, device, "userInfo", expiry_time, refresh_count, status, user_id)
|
||||||
|
VALUES (gen_random_uuid(), $1, 'fayda-verify', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3)
|
||||||
|
ON CONFLICT (user_id, device) DO UPDATE
|
||||||
|
SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo",
|
||||||
|
expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW()
|
||||||
|
RETURNING id`,
|
||||||
|
[u.email ?? '', JSON.stringify(userInfo), iamUserId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessionId = sessions[0].id;
|
||||||
|
const token = generateToken({ id: sessionId });
|
||||||
|
const refreshToken = generateRefreshToken({ id: sessionId });
|
||||||
|
|
||||||
|
return { token, refreshToken, requiresPassword: !u.has_set_password };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async markSessionFailed(
|
||||||
|
state: string,
|
||||||
|
errorCode: string,
|
||||||
|
errorDescription?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.sessionRepo.update(
|
||||||
|
{ state, status: 'PENDING' },
|
||||||
|
{
|
||||||
|
status: 'FAILED',
|
||||||
|
errorCode,
|
||||||
|
errorDescription: errorDescription ?? null,
|
||||||
|
completedAt: new Date(),
|
||||||
|
codeVerifier: '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private classifyFailureReason(err: unknown): string {
|
||||||
|
if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed';
|
||||||
|
if (err instanceof FaydaUserInfoException) return 'userinfo_failed';
|
||||||
|
return 'verification_failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export type VerifaydaPurpose = 'LOGIN' | 'VERIFY';
|
||||||
|
|
||||||
|
export interface FaydaTokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
id_token?: string;
|
||||||
|
token_type: string;
|
||||||
|
expires_in?: number;
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FaydaUserInfo {
|
||||||
|
sub: string;
|
||||||
|
name?: string;
|
||||||
|
'name#en'?: string;
|
||||||
|
'name#am'?: string;
|
||||||
|
phone_number?: string;
|
||||||
|
'phone_number#en'?: string;
|
||||||
|
'phone_number#am'?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
gender?: string;
|
||||||
|
birthdate?: string;
|
||||||
|
picture?: string;
|
||||||
|
address?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedFaydaUserInfo {
|
||||||
|
sub: string;
|
||||||
|
// Convenience / display fields
|
||||||
|
fullName?: string;
|
||||||
|
phoneNumber?: string; // standardized e.g. +251911234567
|
||||||
|
email?: string;
|
||||||
|
gender?: string;
|
||||||
|
birthdate?: string;
|
||||||
|
picture?: string;
|
||||||
|
// Raw localized fields — preserved for IAM-identical writes
|
||||||
|
nameEn?: string;
|
||||||
|
nameAm?: string;
|
||||||
|
genderEn?: string;
|
||||||
|
genderAm?: string;
|
||||||
|
addressEn?: string;
|
||||||
|
addressAm?: string;
|
||||||
|
rawPhoneNumber?: string; // unstandardized, stored in IAM metadata
|
||||||
|
}
|
||||||
@@ -120,6 +120,7 @@ import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
|
|||||||
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
|
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
|
||||||
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
|
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
|
||||||
import { HealthCheck } from "./features/health/HealthCheck";
|
import { HealthCheck } from "./features/health/HealthCheck";
|
||||||
|
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
|
||||||
|
|
||||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||||
{
|
{
|
||||||
@@ -567,6 +568,7 @@ const App = () => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/auth" element={<LoginPage />} />
|
<Route path="/auth" element={<LoginPage />} />
|
||||||
<Route path="/um/*" element={<UserManagementHostPage />} />
|
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||||
|
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||||
<Route path="*" element={<Navigate to="/auth" replace />} />
|
<Route path="*" element={<Navigate to="/auth" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
@@ -576,6 +578,7 @@ const App = () => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/um/*" element={<UserManagementHostPage />} />
|
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||||
<Route path="/health" element={<HealthCheck />} />
|
<Route path="/health" element={<HealthCheck />} />
|
||||||
|
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Loader2, Calendar } from "lucide-react";
|
import { Loader2, ShieldCheck } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -12,7 +14,6 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
TextInput,
|
TextInput,
|
||||||
ActionIcon,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +21,10 @@ import {
|
|||||||
type FleetFormFieldDef,
|
type FleetFormFieldDef,
|
||||||
} from "@/pages/fleet/config/resources";
|
} from "@/pages/fleet/config/resources";
|
||||||
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||||
|
import {
|
||||||
|
verifaydaService,
|
||||||
|
type FaydaCallbackMessage,
|
||||||
|
} from "@/services/verifayda.service";
|
||||||
|
|
||||||
export interface FleetFormDialogProps {
|
export interface FleetFormDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -31,8 +36,32 @@ export interface FleetFormDialogProps {
|
|||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
selectOptionsLoading?: boolean;
|
selectOptionsLoading?: boolean;
|
||||||
onSubmit: (values: Record<string, unknown>) => void;
|
onSubmit: (values: Record<string, unknown>) => void;
|
||||||
|
/**
|
||||||
|
* Show a "Verify with Fayda" step: opens the eSignet popup and prefills
|
||||||
|
* firstName/lastName/email/phoneNumber/dateOfBirth from the verified
|
||||||
|
* identity, stamping faydaVerified + faydaSub on the payload.
|
||||||
|
*/
|
||||||
|
verifyWithFayda?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fayda returns gender as "Male"/"Female"; snap it onto the form's uppercase
|
||||||
|
// option values (MALE/FEMALE/OTHER) so the Select prefills instead of rendering
|
||||||
|
// blank. Unknown/empty values fall through to undefined (field left untouched).
|
||||||
|
const normalizeGender = (raw?: string): string | undefined => {
|
||||||
|
const up = (raw ?? "").trim().toUpperCase();
|
||||||
|
if (up === "MALE" || up === "M") return "MALE";
|
||||||
|
if (up === "FEMALE" || up === "F") return "FEMALE";
|
||||||
|
return up ? "OTHER" : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fayda may return the birthdate as "2001/12/01" (slashes), but the date input
|
||||||
|
// and validator expect ISO "2001-12-01". Normalize separators + trim to 10 chars
|
||||||
|
// so the DOB field prefills instead of silently staying blank.
|
||||||
|
const normalizeBirthdate = (raw?: string): string | undefined => {
|
||||||
|
const iso = (raw ?? "").trim().replace(/\//g, "-").slice(0, 10);
|
||||||
|
return /^\d{4}-\d{2}-\d{2}$/.test(iso) ? iso : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const buildInitialValues = (
|
const buildInitialValues = (
|
||||||
fields: FleetFormFieldDef[],
|
fields: FleetFormFieldDef[],
|
||||||
emptyValues: Record<string, unknown>,
|
emptyValues: Record<string, unknown>,
|
||||||
@@ -72,9 +101,12 @@ const FleetFormDialog = ({
|
|||||||
isSubmitting,
|
isSubmitting,
|
||||||
selectOptionsLoading,
|
selectOptionsLoading,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
verifyWithFayda,
|
||||||
}: FleetFormDialogProps) => {
|
}: FleetFormDialogProps) => {
|
||||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [faydaLoading, setFaydaLoading] = useState(false);
|
||||||
|
const [faydaError, setFaydaError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Seed the form ONLY when the dialog opens or the edited record changes — NOT
|
// Seed the form ONLY when the dialog opens or the edited record changes — NOT
|
||||||
// when `fields`/`emptyValues` get new object refs (they're rebuilt whenever the
|
// when `fields`/`emptyValues` get new object refs (they're rebuilt whenever the
|
||||||
@@ -87,10 +119,88 @@ const FleetFormDialog = ({
|
|||||||
if (open) {
|
if (open) {
|
||||||
setValues(buildInitialValues(fields, emptyValues, initialRecord));
|
setValues(buildInitialValues(fields, emptyValues, initialRecord));
|
||||||
setErrors({});
|
setErrors({});
|
||||||
|
setFaydaError(null);
|
||||||
|
setFaydaLoading(false);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, recordId]);
|
}, [open, recordId]);
|
||||||
|
|
||||||
|
// Receive the ?code&state relayed by the /callback popup, exchange it for
|
||||||
|
// the verified identity, and prefill the matching form fields.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !verifyWithFayda) return;
|
||||||
|
const onMessage = async (event: MessageEvent<FaydaCallbackMessage>) => {
|
||||||
|
if (event.origin !== window.location.origin) return;
|
||||||
|
if (event.data?.type !== "fayda-callback") return;
|
||||||
|
|
||||||
|
if (event.data.error) {
|
||||||
|
setFaydaLoading(false);
|
||||||
|
setFaydaError(event.data.errorDescription ?? event.data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!event.data.code || !event.data.state) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await verifaydaService.complete(event.data.code, event.data.state);
|
||||||
|
if (!result.verified) {
|
||||||
|
setFaydaError("Identity could not be verified");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nameParts = (result.fullName ?? "").trim().split(/\s+/).filter(Boolean);
|
||||||
|
const [firstName, ...rest] = nameParts;
|
||||||
|
const gender = normalizeGender(result.gender);
|
||||||
|
const dateOfBirth = normalizeBirthdate(result.birthdate);
|
||||||
|
setValues((current) => ({
|
||||||
|
...current,
|
||||||
|
...(firstName ? { firstName } : {}),
|
||||||
|
...(rest.length ? { lastName: rest.join(" ") } : {}),
|
||||||
|
...(result.email ? { email: result.email } : {}),
|
||||||
|
...(result.phoneNumber ? { phoneNumber: result.phoneNumber } : {}),
|
||||||
|
...(dateOfBirth ? { dateOfBirth } : {}),
|
||||||
|
...(gender ? { gender } : {}),
|
||||||
|
faydaVerified: true,
|
||||||
|
...(result.iamUserId ? { faydaSub: result.iamUserId } : {}),
|
||||||
|
}));
|
||||||
|
setFaydaError(null);
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||||
|
(err instanceof Error ? err.message : "Verification failed");
|
||||||
|
setFaydaError(message);
|
||||||
|
} finally {
|
||||||
|
setFaydaLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("message", onMessage);
|
||||||
|
return () => window.removeEventListener("message", onMessage);
|
||||||
|
}, [open, verifyWithFayda]);
|
||||||
|
|
||||||
|
const handleFaydaVerify = async () => {
|
||||||
|
setFaydaError(null);
|
||||||
|
setFaydaLoading(true);
|
||||||
|
try {
|
||||||
|
const { authorizationUrl } = await verifaydaService.start();
|
||||||
|
const popup = window.open(
|
||||||
|
authorizationUrl,
|
||||||
|
"fayda-verify",
|
||||||
|
"width=480,height=760,noopener=no",
|
||||||
|
);
|
||||||
|
if (!popup) {
|
||||||
|
setFaydaLoading(false);
|
||||||
|
setFaydaError("Pop-up blocked — allow pop-ups for this site and retry.");
|
||||||
|
}
|
||||||
|
// Loading stays on until the popup posts back; reopening the dialog resets it.
|
||||||
|
} catch (err) {
|
||||||
|
setFaydaLoading(false);
|
||||||
|
const message =
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||||
|
(err instanceof Error ? err.message : "Could not start verification");
|
||||||
|
setFaydaError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const faydaVerified = values.faydaVerified === true;
|
||||||
|
|
||||||
const shortFields = useMemo(
|
const shortFields = useMemo(
|
||||||
() => fields.filter((f) => f.type !== "textarea"),
|
() => fields.filter((f) => f.type !== "textarea"),
|
||||||
[fields],
|
[fields],
|
||||||
@@ -127,6 +237,12 @@ const FleetFormDialog = ({
|
|||||||
const date = new Date(stringValue + "T00:00:00Z");
|
const date = new Date(stringValue + "T00:00:00Z");
|
||||||
if (isNaN(date.getTime())) {
|
if (isNaN(date.getTime())) {
|
||||||
next[field.name] = `${field.label} is not a valid date`;
|
next[field.name] = `${field.label} is not a valid date`;
|
||||||
|
} else if (field.dateBound === "future") {
|
||||||
|
const startOfToday = new Date();
|
||||||
|
startOfToday.setUTCHours(0, 0, 0, 0);
|
||||||
|
if (date <= startOfToday) {
|
||||||
|
next[field.name] = `${field.label} must be in the future`;
|
||||||
|
}
|
||||||
} else if (date > new Date()) {
|
} else if (date > new Date()) {
|
||||||
next[field.name] = `${field.label} cannot be in the future`;
|
next[field.name] = `${field.label} cannot be in the future`;
|
||||||
}
|
}
|
||||||
@@ -147,6 +263,12 @@ const FleetFormDialog = ({
|
|||||||
}, [fields]);
|
}, [fields]);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
// Hard gate: a driver record cannot be saved until its identity is verified
|
||||||
|
// with Fayda. Mirrored server-side in DriversService.
|
||||||
|
if (verifyWithFayda && !faydaVerified) {
|
||||||
|
setFaydaError("Verify the driver's identity with Fayda before saving.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!validate()) return;
|
if (!validate()) return;
|
||||||
const payload = Object.fromEntries(
|
const payload = Object.fromEntries(
|
||||||
Object.entries(values)
|
Object.entries(values)
|
||||||
@@ -167,6 +289,9 @@ const FleetFormDialog = ({
|
|||||||
const renderField = (field: FleetFormFieldDef) => {
|
const renderField = (field: FleetFormFieldDef) => {
|
||||||
const value = values[field.name];
|
const value = values[field.name];
|
||||||
const error = errors[field.name];
|
const error = errors[field.name];
|
||||||
|
// Fayda-owned identity fields (name/email/phone/DOB/gender) are populated
|
||||||
|
// only by verification and never hand-edited.
|
||||||
|
const isDisabled = Boolean(field.disabled || field.faydaLocked);
|
||||||
|
|
||||||
if (field.type === "select") {
|
if (field.type === "select") {
|
||||||
return (
|
return (
|
||||||
@@ -186,7 +311,7 @@ const FleetFormDialog = ({
|
|||||||
}
|
}
|
||||||
error={error}
|
error={error}
|
||||||
searchable
|
searchable
|
||||||
disabled={selectOptionsLoading}
|
disabled={selectOptionsLoading || isDisabled}
|
||||||
rightSection={
|
rightSection={
|
||||||
selectOptionsLoading ? (
|
selectOptionsLoading ? (
|
||||||
<Loader2 size={14} className="animate-spin" />
|
<Loader2 size={14} className="animate-spin" />
|
||||||
@@ -216,7 +341,7 @@ const FleetFormDialog = ({
|
|||||||
error={error}
|
error={error}
|
||||||
searchable
|
searchable
|
||||||
clearable
|
clearable
|
||||||
disabled={selectOptionsLoading}
|
disabled={selectOptionsLoading || isDisabled}
|
||||||
rightSection={
|
rightSection={
|
||||||
selectOptionsLoading ? (
|
selectOptionsLoading ? (
|
||||||
<Loader2 size={14} className="animate-spin" />
|
<Loader2 size={14} className="animate-spin" />
|
||||||
@@ -240,7 +365,7 @@ const FleetFormDialog = ({
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
error={error}
|
error={error}
|
||||||
disabled={field.disabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -260,7 +385,7 @@ const FleetFormDialog = ({
|
|||||||
}
|
}
|
||||||
error={error}
|
error={error}
|
||||||
minRows={3}
|
minRows={3}
|
||||||
disabled={field.disabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -280,13 +405,8 @@ const FleetFormDialog = ({
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
error={error}
|
error={error}
|
||||||
disabled={field.disabled}
|
disabled={isDisabled}
|
||||||
description={field.description || "Select a date"}
|
description={field.description || "Select a date"}
|
||||||
rightSection={
|
|
||||||
<ActionIcon size="sm" variant="subtle" color="green">
|
|
||||||
<Calendar size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
styles={{
|
styles={{
|
||||||
@@ -318,7 +438,7 @@ const FleetFormDialog = ({
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
error={error}
|
error={error}
|
||||||
disabled={field.disabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -333,6 +453,40 @@ const FleetFormDialog = ({
|
|||||||
centered
|
centered
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
{verifyWithFayda && (
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
{faydaVerified ? (
|
||||||
|
<Badge
|
||||||
|
color="green"
|
||||||
|
variant="light"
|
||||||
|
size="lg"
|
||||||
|
leftSection={<ShieldCheck size={14} />}
|
||||||
|
>
|
||||||
|
Identity verified with Fayda
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Identity must be verified with Fayda before this driver can be
|
||||||
|
saved.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant={faydaVerified ? "default" : "light"}
|
||||||
|
color="edr-green"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<ShieldCheck size={14} />}
|
||||||
|
loading={faydaLoading}
|
||||||
|
onClick={handleFaydaVerify}
|
||||||
|
>
|
||||||
|
{faydaVerified ? "Re-verify" : "Verify with Fayda"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{verifyWithFayda && faydaError && (
|
||||||
|
<Alert color="red" variant="light">
|
||||||
|
{faydaError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
{shortFields.map(renderField)}
|
{shortFields.map(renderField)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
@@ -345,6 +499,7 @@ const FleetFormDialog = ({
|
|||||||
color="edr-green"
|
color="edr-green"
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
|
disabled={verifyWithFayda && !faydaVerified}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Badge, Text } from "@mantine/core";
|
|||||||
import type { ColumnFormat } from "@/pages/ruleEngine/config/resources";
|
import type { ColumnFormat } from "@/pages/ruleEngine/config/resources";
|
||||||
import { formatCell as formatRuleEngineCell } from "@/components/ruleEngine/ruleEngineFormat";
|
import { formatCell as formatRuleEngineCell } from "@/components/ruleEngine/ruleEngineFormat";
|
||||||
|
|
||||||
export type FleetColumnFormat = ColumnFormat | "statusBadge";
|
export type FleetColumnFormat = ColumnFormat | "statusBadge" | "verifiedBadge";
|
||||||
|
|
||||||
const optionLabelMap = new Map<string, Map<string, string>>();
|
const optionLabelMap = new Map<string, Map<string, string>>();
|
||||||
|
|
||||||
@@ -20,6 +20,16 @@ export const formatFleetCell = (
|
|||||||
format?: FleetColumnFormat,
|
format?: FleetColumnFormat,
|
||||||
accessorKey?: string,
|
accessorKey?: string,
|
||||||
): ReactNode => {
|
): ReactNode => {
|
||||||
|
if (format === "verifiedBadge") {
|
||||||
|
return value === true ? (
|
||||||
|
<Badge variant="light" color="green" size="sm" radius="md">
|
||||||
|
Verified
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">—</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (format === "statusBadge") {
|
if (format === "statusBadge") {
|
||||||
const status = value == null || value === "" ? "—" : String(value);
|
const status = value == null || value === "" ? "—" : String(value);
|
||||||
const getStatusColor = (st: string): string => {
|
const getStatusColor = (st: string): string => {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Center, Loader, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { FaydaCallbackMessage } from "@/services/verifayda.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Landing page for the eSignet redirect_uri (FAYDA_WEB_REDIRECT_URI →
|
||||||
|
* http://localhost:5183/callback). Runs inside the verification popup:
|
||||||
|
* relays ?code&state (or ?error) to the window that opened it via
|
||||||
|
* postMessage, then closes itself. The opener performs the /complete call
|
||||||
|
* so the single-use session is only consumed once, in one place.
|
||||||
|
*/
|
||||||
|
const FaydaCallbackPage = () => {
|
||||||
|
const [standalone, setStandalone] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const message: FaydaCallbackMessage = {
|
||||||
|
type: "fayda-callback",
|
||||||
|
code: params.get("code") ?? undefined,
|
||||||
|
state: params.get("state") ?? undefined,
|
||||||
|
error: params.get("error") ?? undefined,
|
||||||
|
errorDescription: params.get("error_description") ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window.opener && window.opener !== window) {
|
||||||
|
(window.opener as Window).postMessage(message, window.location.origin);
|
||||||
|
window.close();
|
||||||
|
} else {
|
||||||
|
// Opened as a full-page redirect instead of a popup — nothing to relay to.
|
||||||
|
setStandalone(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center h="100vh">
|
||||||
|
<Stack align="center" gap="sm">
|
||||||
|
{standalone ? (
|
||||||
|
<>
|
||||||
|
<Text fw={600}>Verification window lost its parent page</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Close this tab and restart the verification from the form.
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Loader size="sm" />
|
||||||
|
<Text size="sm" c="dimmed">Completing Fayda verification…</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FaydaCallbackPage;
|
||||||
@@ -510,6 +510,7 @@ const FleetResourcePage = () => {
|
|||||||
isSubmitting={create.isPending || update.isPending}
|
isSubmitting={create.isPending || update.isPending}
|
||||||
selectOptionsLoading={selectOptionsLoading}
|
selectOptionsLoading={selectOptionsLoading}
|
||||||
onSubmit={handleFormSubmit}
|
onSubmit={handleFormSubmit}
|
||||||
|
verifyWithFayda={Boolean(config.faydaVerification)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ const DRIVER_STATUS_OPTIONS = [
|
|||||||
{ label: "On leave", value: "ON_LEAVE" },
|
{ label: "On leave", value: "ON_LEAVE" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const DRIVER_GENDER_OPTIONS = [
|
||||||
|
{ label: "Male", value: "MALE" },
|
||||||
|
{ label: "Female", value: "FEMALE" },
|
||||||
|
{ label: "Other", value: "OTHER" },
|
||||||
|
];
|
||||||
|
|
||||||
export const driversConfig: FleetResourceConfig = {
|
export const driversConfig: FleetResourceConfig = {
|
||||||
slug: "drivers",
|
slug: "drivers",
|
||||||
label: "Drivers",
|
label: "Drivers",
|
||||||
@@ -29,24 +35,28 @@ export const driversConfig: FleetResourceConfig = {
|
|||||||
options: DRIVER_STATUS_OPTIONS,
|
options: DRIVER_STATUS_OPTIONS,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
faydaVerification: true,
|
||||||
searchKeys: ["firstName", "lastName", "email", "phoneNumber", "licenseNumber", "status"],
|
searchKeys: ["firstName", "lastName", "email", "phoneNumber", "licenseNumber", "status"],
|
||||||
columns: [
|
columns: [
|
||||||
{ id: "licenseNumber", header: "License Number", accessorKey: "licenseNumber", format: "code", size: 140 },
|
{ id: "licenseNumber", header: "Driver's License Number", accessorKey: "licenseNumber", format: "code", size: 180 },
|
||||||
{ id: "firstName", header: "First Name", accessorKey: "firstName", format: "code", size: 120 },
|
{ id: "firstName", header: "First Name", accessorKey: "firstName", format: "code", size: 120 },
|
||||||
{ id: "lastName", header: "Last Name", accessorKey: "lastName", format: "code", size: 120 },
|
{ id: "lastName", header: "Last Name", accessorKey: "lastName", format: "code", size: 120 },
|
||||||
{ id: "email", header: "Email", accessorKey: "email", format: "code", size: 180 },
|
{ id: "email", header: "Email", accessorKey: "email", format: "code", size: 180 },
|
||||||
{ id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber", format: "code", size: 120 },
|
{ id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber", format: "code", size: 120 },
|
||||||
|
{ id: "gender", header: "Gender", accessorKey: "gender", format: "code", size: 90 },
|
||||||
{ id: "licenseExpiryDate", header: "License Expiry", accessorKey: "licenseExpiryDate", format: "code", size: 130 },
|
{ id: "licenseExpiryDate", header: "License Expiry", accessorKey: "licenseExpiryDate", format: "code", size: 130 },
|
||||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
|
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
|
||||||
|
{ id: "faydaVerified", header: "Fayda", accessorKey: "faydaVerified", format: "verifiedBadge", size: 90 },
|
||||||
],
|
],
|
||||||
formFields: [
|
formFields: [
|
||||||
{ name: "licenseNumber", label: "License Number", type: "text", required: true },
|
{ name: "licenseNumber", label: "Driver's License Number", type: "text", required: true },
|
||||||
{ name: "firstName", label: "First Name", type: "text", required: true },
|
{ name: "firstName", label: "First Name", type: "text", required: true, faydaLocked: true },
|
||||||
{ name: "lastName", label: "Last Name", type: "text", required: true },
|
{ name: "lastName", label: "Last Name", type: "text", required: true, faydaLocked: true },
|
||||||
{ name: "email", label: "Email", type: "email", required: true },
|
{ name: "email", label: "Email", type: "email", required: true, faydaLocked: true },
|
||||||
{ name: "phoneNumber", label: "Phone Number", type: "text", required: true },
|
{ name: "phoneNumber", label: "Phone Number", type: "text", required: true, faydaLocked: true },
|
||||||
{ name: "dateOfBirth", label: "Date of Birth", type: "date", required: true },
|
{ name: "dateOfBirth", label: "Date of Birth", type: "date", required: true, faydaLocked: true },
|
||||||
{ name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true },
|
{ name: "gender", label: "Gender", type: "select", options: DRIVER_GENDER_OPTIONS, faydaLocked: true },
|
||||||
|
{ name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true, dateBound: "future" },
|
||||||
{ name: "status", label: "Status", type: "select", required: true, options: DRIVER_STATUS_OPTIONS },
|
{ name: "status", label: "Status", type: "select", required: true, options: DRIVER_STATUS_OPTIONS },
|
||||||
{ name: "vehicleTypesAuthorized", label: "Authorized Vehicle Types", type: "multiselect", options: VEHICLE_TYPE_OPTIONS },
|
{ name: "vehicleTypesAuthorized", label: "Authorized Vehicle Types", type: "multiselect", options: VEHICLE_TYPE_OPTIONS },
|
||||||
{ name: "address", label: "Address", type: "textarea" },
|
{ name: "address", label: "Address", type: "textarea" },
|
||||||
@@ -60,6 +70,7 @@ export const driversConfig: FleetResourceConfig = {
|
|||||||
email: "",
|
email: "",
|
||||||
phoneNumber: "",
|
phoneNumber: "",
|
||||||
dateOfBirth: "",
|
dateOfBirth: "",
|
||||||
|
gender: "",
|
||||||
licenseExpiryDate: "",
|
licenseExpiryDate: "",
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
vehicleTypesAuthorized: [],
|
vehicleTypesAuthorized: [],
|
||||||
|
|||||||
@@ -33,13 +33,23 @@ export interface FleetResourceColumn {
|
|||||||
id: string;
|
id: string;
|
||||||
header: string;
|
header: string;
|
||||||
accessorKey: string;
|
accessorKey: string;
|
||||||
format?: ColumnFormat | "statusBadge";
|
format?: ColumnFormat | "statusBadge" | "verifiedBadge";
|
||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FleetFormFieldDef extends FormFieldDef {
|
export interface FleetFormFieldDef extends FormFieldDef {
|
||||||
dynamicOptions?: FleetDynamicOptions;
|
dynamicOptions?: FleetDynamicOptions;
|
||||||
noneOption?: boolean;
|
noneOption?: boolean;
|
||||||
|
/**
|
||||||
|
* Field is owned by the Fayda identity — populated only by verification and
|
||||||
|
* never hand-edited. Rendered disabled in the form.
|
||||||
|
*/
|
||||||
|
faydaLocked?: boolean;
|
||||||
|
/**
|
||||||
|
* Direction a `date` field is constrained to. "future" = must be after today
|
||||||
|
* (e.g. a license expiry); "past" (default) = cannot be in the future.
|
||||||
|
*/
|
||||||
|
dateBound?: "past" | "future";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FleetListFilterDef {
|
export interface FleetListFilterDef {
|
||||||
@@ -73,6 +83,8 @@ export interface FleetResourceConfig {
|
|||||||
cardCodeKey?: string;
|
cardCodeKey?: string;
|
||||||
cardSubtitleKey?: string;
|
cardSubtitleKey?: string;
|
||||||
searchKeys: string[];
|
searchKeys: string[];
|
||||||
|
/** Offer Fayda identity verification in the add/edit form (drivers). */
|
||||||
|
faydaVerification?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FLEET_BASE_PATH_BY_SLUG: Record<FleetResourceSlug, string> = {
|
export const FLEET_BASE_PATH_BY_SLUG: Record<FleetResourceSlug, string> = {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export interface Driver {
|
|||||||
address?: string | null;
|
address?: string | null;
|
||||||
emergencyContact?: string | null;
|
emergencyContact?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
|
faydaVerified?: boolean;
|
||||||
|
faydaSub?: string | null;
|
||||||
totalTrips: number;
|
totalTrips: number;
|
||||||
rating: number;
|
rating: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { api as apiClient } from '../auth/http';
|
||||||
|
|
||||||
|
export interface FaydaStartResponse {
|
||||||
|
authorizationUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FaydaCompleteResult {
|
||||||
|
purpose: 'LOGIN' | 'VERIFY';
|
||||||
|
verified: boolean;
|
||||||
|
fullName?: string;
|
||||||
|
email?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
/** ISO yyyy-MM-dd */
|
||||||
|
birthdate?: string;
|
||||||
|
gender?: string;
|
||||||
|
iamUserId?: string;
|
||||||
|
userDataSaved?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Message posted from the /callback popup back to the opener window. */
|
||||||
|
export interface FaydaCallbackMessage {
|
||||||
|
type: 'fayda-callback';
|
||||||
|
code?: string;
|
||||||
|
state?: string;
|
||||||
|
error?: string;
|
||||||
|
errorDescription?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const verifaydaService = {
|
||||||
|
/** Returns the eSignet authorize URL to open in a popup. */
|
||||||
|
start: () =>
|
||||||
|
apiClient
|
||||||
|
.post<FaydaStartResponse>('/fayda/verification/start', {
|
||||||
|
purpose: 'VERIFY',
|
||||||
|
platform: 'WEB',
|
||||||
|
})
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
/** Exchange the callback code+state for the verified identity attributes. */
|
||||||
|
complete: (code: string, state: string) =>
|
||||||
|
apiClient
|
||||||
|
.get<FaydaCompleteResult>(
|
||||||
|
`/fayda/verification/complete?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
};
|
||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -117,6 +117,9 @@ importers:
|
|||||||
handlebars:
|
handlebars:
|
||||||
specifier: ^4.7.9
|
specifier: ^4.7.9
|
||||||
version: 4.7.9
|
version: 4.7.9
|
||||||
|
jose:
|
||||||
|
specifier: ^5.10.0
|
||||||
|
version: 5.10.0
|
||||||
libphonenumber-js:
|
libphonenumber-js:
|
||||||
specifier: ^1.13.6
|
specifier: ^1.13.6
|
||||||
version: 1.13.6
|
version: 1.13.6
|
||||||
|
|||||||
Reference in New Issue
Block a user