diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 140bc1d01..472be258b 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -4,10 +4,10 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --port 5183 --clearScreen false", + "dev": "vite --port 3000 --clearScreen false", "prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"", "build": "vite build", - "preview": "vite preview --port 5183", + "preview": "vite preview --port 3000", "lint": "eslint src", "test": "vitest run", "type-check": "tsc -b" @@ -16,23 +16,84 @@ "@edr/types": "workspace:*", "@edr/ui-common": "workspace:*", "@hello-pangea/dnd": "^18.0.1", + "@hookform/resolvers": "^5.4.0", "@mantine/core": "^9.3.0", "@mantine/dates": "^9.3.0", "@mantine/hooks": "^9.3.0", + "@radix-ui/react-accordion": "^1.2.13", + "@radix-ui/react-alert-dialog": "^1.1.16", + "@radix-ui/react-avatar": "^1.1.12", + "@radix-ui/react-checkbox": "^1.3.4", + "@radix-ui/react-collapsible": "^1.1.13", + "@radix-ui/react-context-menu": "^2.3.0", + "@radix-ui/react-dialog": "^1.1.16", + "@radix-ui/react-dropdown-menu": "^2.1.17", + "@radix-ui/react-hover-card": "^1.1.16", + "@radix-ui/react-label": "^2.1.9", + "@radix-ui/react-navigation-menu": "^1.2.15", + "@radix-ui/react-popover": "^1.1.16", + "@radix-ui/react-progress": "^1.1.9", + "@radix-ui/react-radio-group": "^1.4.0", + "@radix-ui/react-scroll-area": "^1.2.11", + "@radix-ui/react-select": "^2.3.0", + "@radix-ui/react-separator": "^1.1.9", + "@radix-ui/react-slider": "^1.4.0", + "@radix-ui/react-slot": "^1.2.5", + "@radix-ui/react-switch": "^1.3.0", + "@radix-ui/react-tabs": "^1.1.14", + "@radix-ui/react-toast": "^1.2.16", + "@radix-ui/react-toggle-group": "^1.1.12", + "@radix-ui/react-tooltip": "^1.2.9", + "@react-pdf-viewer/core": "^3.12.0", + "@react-pdf-viewer/default-layout": "^3.12.0", + "@react-pdf-viewer/zoom": "^3.12.0", + "@react-pdf/renderer": "^4.5.1", + "@reduxjs/toolkit": "^2.12.0", "@tabler/icons-react": "^3.44.0", "@tanstack/react-query": "^5.100.11", + "@tanstack/react-table": "^8.21.3", + "@tinymce/tinymce-react": "^6.3.0", "@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz", "@vis.gl/react-google-maps": "^1.8.3", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^3.6.0", + "dayjs": "^1.11.21", + "dompurify": "^3.4.8", + "ethiopian-calendar-date-converter": "^2.1.6", + "file-type": "^18.7.0", + "framer-motion": "^12.40.0", + "html2canvas": "^1.4.1", + "i18next": "^26.3.5", + "jquery": "^3.7.1", + "js-cookie": "^3.0.8", + "jspdf": "^3.0.4", "libphonenumber-js": "^1.12.24", + "lodash": "^4.18.1", "lucide-react": "^1.14.0", + "next-themes": "^0.4.6", + "pdf-lib": "^1.17.1", + "prop-types": "^15.8.1", + "qs": "^6.15.2", "radix-ui": "^1.4.3", "react": "19.2.6", + "react-css-nocode-editor": "^1.0.13", + "react-day-picker": "^9.14.0", "react-dom": "19.2.6", + "react-dropzone": "^14.4.1", + "react-hook-form": "^7.77.0", "react-hot-toast": "^2.6.0", + "react-i18next": "^17.0.8", + "react-icons": "^5.6.0", + "react-image-crop": "^11.0.10", + "react-intersection-observer": "^9.16.0", + "react-pdf": "^10.4.1", + "react-pdf-html": "^2.1.5", + "react-resizable-panels": "^3.0.6", "react-router-dom": "^6.27.0", + "react-signature-canvas": "1.1.0-alpha.2", "recharts": "^3.8.1", "socket.io-client": "^4.8.3", "sonner": "^2.0.7", @@ -40,6 +101,7 @@ "tailwind-merge": "^3.6.0", "tinymce": "^8.6.0", "xlsx": "^0.18.5", + "zod": "^3.25.76", "zustand": "^5.0.0" }, "devDependencies": { @@ -47,6 +109,9 @@ "@edr/tsconfig": "workspace:*", "@tailwindcss/vite": "^4.3.0", "@types/google.maps": "^3.65.2", + "@types/jquery": "^3.5.34", + "@types/js-cookie": "^3.0.6", + "@types/lodash": "^4.17.24", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.2", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 15c8cf19c..598f5e223 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -132,6 +132,7 @@ import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; import { HealthCheck } from "./features/health/HealthCheck"; import FaydaCallbackPage from "./pages/FaydaCallbackPage"; +import { UserManagementRoutes } from "./user-management/route"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { @@ -667,6 +668,7 @@ const App = () => { return ( + {UserManagementRoutes()} } /> } /> } /> @@ -1232,18 +1234,18 @@ const App = () => { /> {/* Legacy embedded user management routes */} - } /> + {/* } /> } /> } - /> + /> */} {/* } /> */} - } /> - } /> + } /> */} { + return []; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function getMyShares(..._args: any[]): Promise { + return []; +} diff --git a/apps/edr-freight-web/backoffice/src/DMS/api/dms.http.ts b/apps/edr-freight-web/backoffice/src/DMS/api/dms.http.ts new file mode 100644 index 000000000..d1d3a0f8f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/DMS/api/dms.http.ts @@ -0,0 +1,24 @@ +// STUB — see DMS/pages/_shared/utils.ts. DMS HTTP client, not migrated. +// Returns empty payloads so any DMS call degrades to "no results" rather than +// crashing. Wire the real @/DMS/api/dms.http if DMS is brought over. + +function warn(method: string, url: string) { + // eslint-disable-next-line no-console + console.warn(`[DMS stub] ${method} ${url} — DMS is not migrated; returning empty.`); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function empty(): Promise<{ data: T }> { + return { data: {} as T }; +} + +export const dmsHttp = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get: (url: string): Promise<{ data: T }> => (warn("GET", url), empty()), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + post: (url: string): Promise<{ data: T }> => (warn("POST", url), empty()), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + put: (url: string): Promise<{ data: T }> => (warn("PUT", url), empty()), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete: (url: string): Promise<{ data: T }> => (warn("DELETE", url), empty()), +}; diff --git a/apps/edr-freight-web/backoffice/src/DMS/api/folders.api.ts b/apps/edr-freight-web/backoffice/src/DMS/api/folders.api.ts new file mode 100644 index 000000000..895182597 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/DMS/api/folders.api.ts @@ -0,0 +1,12 @@ +// STUB — see DMS/pages/_shared/utils.ts. DMS folders API, not migrated. +// Returns empty lists so folder pickers render empty instead of crashing. + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function getMySubFolders(..._args: any[]): Promise { + return []; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function getMyCollaborationsSubFolders(..._args: any[]): Promise { + return []; +} diff --git a/apps/edr-freight-web/backoffice/src/DMS/pages/_shared/file-kind.utils.ts b/apps/edr-freight-web/backoffice/src/DMS/pages/_shared/file-kind.utils.ts new file mode 100644 index 000000000..50e2878c0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/DMS/pages/_shared/file-kind.utils.ts @@ -0,0 +1,21 @@ +// STUB — see DMS/pages/_shared/utils.ts. DMS is not migrated; inert placeholder. + +export type FileKind = + | "word" + | "excel" + | "ppt" + | "pdf" + | "image" + | "video" + | "file"; + +export function detectFileKind(nameOrMime?: string | null): FileKind { + const s = (nameOrMime ?? "").toLowerCase(); + if (/\.(docx?|word)/.test(s)) return "word"; + if (/\.(xlsx?|csv)/.test(s)) return "excel"; + if (/\.(pptx?)/.test(s)) return "ppt"; + if (/\.pdf/.test(s)) return "pdf"; + if (/\.(png|jpe?g|gif|webp|svg|image)/.test(s)) return "image"; + if (/\.(mp4|mov|avi|video)/.test(s)) return "video"; + return "file"; +} diff --git a/apps/edr-freight-web/backoffice/src/DMS/pages/_shared/od-icons.tsx b/apps/edr-freight-web/backoffice/src/DMS/pages/_shared/od-icons.tsx new file mode 100644 index 000000000..cb9c26f65 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/DMS/pages/_shared/od-icons.tsx @@ -0,0 +1,14 @@ +// STUB — see DMS/pages/_shared/utils.ts. DMS file-type icons, not migrated. +// Each renders nothing so document components resolve without the real assets. +import type { SVGProps } from "react"; + +const Empty = (_props: SVGProps) => null; + +export const ODWinFolderSvg = Empty; +export const ODIconWord = Empty; +export const ODIconExcel = Empty; +export const ODIconPpt = Empty; +export const ODIconPdf = Empty; +export const ODIconImage = Empty; +export const ODIconVideo = Empty; +export const ODIconFile = Empty; diff --git a/apps/edr-freight-web/backoffice/src/DMS/pages/_shared/utils.ts b/apps/edr-freight-web/backoffice/src/DMS/pages/_shared/utils.ts new file mode 100644 index 000000000..e8ec80618 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/DMS/pages/_shared/utils.ts @@ -0,0 +1,20 @@ +// STUB — @/DMS is not migrated into this backoffice. These placeholders exist +// only so record-management's document components resolve; the DMS feature is +// inert here. Replace with the real @/DMS module if DMS is ever brought over. + +export function formatBytes(bytes?: number | null): string { + if (!bytes || bytes <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i] ?? "B"}`; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function resolveOwnerName(owner?: any): string { + return owner?.name ?? owner?.username ?? owner?.email ?? ""; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function pickName(item?: any): string { + return item?.name ?? item?.title ?? item?.fileName ?? ""; +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/components/ComplaintPageLayout.tsx b/apps/edr-freight-web/backoffice/src/complaints/components/ComplaintPageLayout.tsx new file mode 100644 index 000000000..9aad0a009 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/components/ComplaintPageLayout.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; +import Header from "@/layout/components/Header"; +import Footer from "@/layout/components/Footer"; + +interface ComplaintPageLayoutProps { + children: ReactNode; + heroTitle: string; + heroSubtitle?: string; +} + +export function ComplaintPageLayout({ + children, + heroTitle, + heroSubtitle, +}: ComplaintPageLayoutProps) { + return ( +
+
+
+
+
+

+ {heroTitle} +

+ {heroSubtitle && ( +

+ {heroSubtitle} +

+ )} +
+
{children}
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/components/ComplaintVerificationGuard.tsx b/apps/edr-freight-web/backoffice/src/complaints/components/ComplaintVerificationGuard.tsx new file mode 100644 index 000000000..3b8baddbb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/components/ComplaintVerificationGuard.tsx @@ -0,0 +1,12 @@ +import { Navigate, Outlet } from "react-router-dom"; +import { getComplaintVerification } from "../utils/complaintVerificationStorage"; + +export function ComplaintVerificationGuard() { + const session = getComplaintVerification(); + + if (!session) { + return ; + } + + return ; +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/components/ComplaintVerifiedInfoPanel.tsx b/apps/edr-freight-web/backoffice/src/complaints/components/ComplaintVerifiedInfoPanel.tsx new file mode 100644 index 000000000..0eb48e1ab --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/components/ComplaintVerifiedInfoPanel.tsx @@ -0,0 +1,69 @@ +import { useTranslation } from "react-i18next"; +import { BadgeCheck, Building2, IdCard, User } from "lucide-react"; +import { + PortalFieldLabel, + PortalReadOnlyField, +} from "@/external-portal/components/shared/PortalFormPrimitives"; +import type { ComplaintVerificationSession } from "../types/complaint.types"; + +interface ComplaintVerifiedInfoPanelProps { + session: ComplaintVerificationSession; +} + +export function ComplaintVerifiedInfoPanel({ + session, +}: ComplaintVerifiedInfoPanelProps) { + const { t } = useTranslation(); + const isTinComplaint = session.method === "tin"; + + return ( +
+

+ + {isTinComplaint + ? t("complaint.tin.verifiedInfo") + : t("complaint.fayda.verifiedInfo")} +

+
+ {isTinComplaint ? ( + <> + + + {session.organization?.tradeName ? ( + + ) : null} + {session.organization?.licenseNumber ? ( + + ) : null} + + ) : ( + <> + + + )} +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintCallbackPage.tsx b/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintCallbackPage.tsx new file mode 100644 index 000000000..02f1dc36d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintCallbackPage.tsx @@ -0,0 +1,160 @@ +import { useEffect, useRef, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/shared/common/ui/button"; +import { useAuth } from "@/shared/context/AuthContext"; +import { + isComplaintFaydaState, +} from "@/shared/utils/faydaOidc"; +import { + mapRegisterWithFaydaCitizen, + persistFaydaRegistrationAuth, +} from "@/shared/utils/faydaAuthSession"; +import { + FaydaOidcError, + verifyComplaintUser, +} from "../services/complaintVerificationService"; +import { + clearComplaintVerification, + storeComplaintVerification, +} from "../utils/complaintVerificationStorage"; +import { COMPLAINT_RECORDS_PATH } from "../utils/complaintRoutes"; + +export default function ComplaintCallbackPage() { + const { t, i18n } = useTranslation(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { setUser, setSelectedPositionId } = useAuth(); + const [error, setError] = useState(null); + const hasRun = useRef(false); + + useEffect(() => { + if (hasRun.current) return; + hasRun.current = true; + + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const oidcError = searchParams.get("error"); + const oidcErrorDescription = searchParams.get("error_description"); + + if (oidcError) { + clearComplaintVerification(); + const message = + oidcErrorDescription || + t("complaint.fayda.verificationFailed"); + setError(message); + return; + } + + if (!code) { + clearComplaintVerification(); + setError(t("complaint.fayda.missingCode")); + return; + } + + if (state && !isComplaintFaydaState(state)) { + clearComplaintVerification(); + setError(t("complaint.fayda.verificationFailed")); + return; + } + + const processCallback = async () => { + try { + const result = await verifyComplaintUser( + { code, state }, + i18n.language, + ); + + if (!result.verified || !result.registration) { + throw new Error(t("complaint.fayda.verificationFailed")); + } + + const profile = await persistFaydaRegistrationAuth(result.registration); + if (profile) { + setUser(profile); + const firstPositionId = + profile.employee?.[0]?.positions?.[0]?.employeePositionId; + if (firstPositionId) { + setSelectedPositionId(firstPositionId); + } + } + + const citizen = mapRegisterWithFaydaCitizen( + result.registration, + i18n.language, + profile, + ); + + if (!citizen.fullName && !profile) { + throw new FaydaOidcError("FAYDA_PROFILE_INCOMPLETE"); + } + + storeComplaintVerification({ + verified: true, + method: "fayda", + citizen, + verifiedAt: result.verifiedAt, + }); + + navigate(COMPLAINT_RECORDS_PATH, { + replace: true, + state: { fromComplaintVerification: true }, + }); + } catch (err: unknown) { + clearComplaintVerification(); + if (err instanceof FaydaOidcError) { + const translationKey = `complaint.fayda.errors.${err.code}`; + const translated = t(translationKey); + setError( + translated !== translationKey + ? translated + : t("complaint.fayda.verificationFailed"), + ); + return; + } + + const message = + err instanceof Error + ? err.message + : t("complaint.fayda.verificationFailed"); + setError(message); + } + }; + + processCallback(); + }, [ + searchParams, + navigate, + t, + i18n.language, + setUser, + setSelectedPositionId, + ]); + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + return ( +
+ +

+ {t("complaint.fayda.verifying")} +

+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintFormPage.tsx b/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintFormPage.tsx new file mode 100644 index 000000000..ff552866e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintFormPage.tsx @@ -0,0 +1,13 @@ +import { Navigate } from "react-router-dom"; +import { getComplaintVerification } from "../utils/complaintVerificationStorage"; +import { COMPLAINT_RECORDS_PATH } from "../utils/complaintRoutes"; + +export default function ComplaintFormPage() { + const session = getComplaintVerification(); + + if (!session) { + return ; + } + + return ; +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintMethodChoicePage.tsx b/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintMethodChoicePage.tsx new file mode 100644 index 000000000..d4f791d5d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintMethodChoicePage.tsx @@ -0,0 +1,94 @@ +import { useEffect } from "react"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { AlertCircle, Building2, Search, UserRound } from "lucide-react"; +import { toast } from "sonner"; +import { startComplaintFaydaAuth } from "@/shared/utils/faydaOidc"; +import { ComplaintPageLayout } from "../components/ComplaintPageLayout"; +import { clearComplaintVerification } from "../utils/complaintVerificationStorage"; + +export default function ComplaintMethodChoicePage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const error = searchParams.get("error"); + + useEffect(() => { + clearComplaintVerification(); + }, []); + + useEffect(() => { + if (error) { + toast.error(error); + setSearchParams({}, { replace: true }); + } + }, [error, setSearchParams]); + + const authOptions = [ + { + provider: "fayda" as const, + title: t("registration.auth.continueWithFayda"), + description: t("registration.auth.faydaDescription"), + icon: UserRound, + onClick: () => startComplaintFaydaAuth(), + }, + { + provider: "etrade" as const, + title: t("registration.auth.continueWithEtrade"), + description: t("registration.auth.etradeDescription"), + icon: Building2, + onClick: () => navigate("/complaints/tin"), + }, + ]; + + return ( + +
+ {error && ( +
+ +

{error}

+
+ )} + +

+ {t("complaint.choice.prompt")} +

+ +
+ {authOptions.map((option) => { + const Icon = option.icon; + return ( + + ); + })} +
+ +
+ + + {t("complaint.followTitle")} + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintSuccessPage.tsx b/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintSuccessPage.tsx new file mode 100644 index 000000000..ea294f8f1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintSuccessPage.tsx @@ -0,0 +1,44 @@ +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { CheckCircle, Search } from "lucide-react"; +import { Button } from "@/shared/common/ui/button"; +import { ComplaintPageLayout } from "../components/ComplaintPageLayout"; +import { clearComplaintVerification } from "../utils/complaintVerificationStorage"; + +export default function ComplaintSuccessPage() { + const { t } = useTranslation(); + + const handleDone = () => { + clearComplaintVerification(); + }; + + return ( + +
+ +

+ {t("complaint.fayda.successMessage")} +

+

+ {t("complaint.fayda.successNote")} +

+ +
+ + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintTinVerificationPage.tsx b/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintTinVerificationPage.tsx new file mode 100644 index 000000000..fa5ea062c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintTinVerificationPage.tsx @@ -0,0 +1,308 @@ +import { FormEvent, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { AlertCircle, ArrowLeft, Building2, Loader2 } from "lucide-react"; +import { Button } from "@/shared/common/ui/button"; +import { Input } from "@/shared/common/ui/input"; +import { Label } from "@/shared/common/ui/label"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; +import { useAuth } from "@/shared/context/AuthContext"; +import { registerWithEtrade } from "@/shared/services/authService"; +import { persistFaydaRegistrationAuth } from "@/shared/utils/faydaAuthSession"; +import { ComplaintPageLayout } from "../components/ComplaintPageLayout"; +import { TinRegistrationNotFoundError } from "../services/complaintVerificationService"; +import { storeComplaintVerification } from "../utils/complaintVerificationStorage"; +import { COMPLAINT_RECORDS_PATH } from "../utils/complaintRoutes"; +import type { VerifiedOrganization } from "../types/complaint.types"; +import { + EtradeBusinessLicenseOption, + fetchRegistrationByTin, + mapEtradeBusinessLicenses, + mapEtradeRegistration, + resolveEtradeLanguage, + resolveEtradePhoneForSignup, +} from "../services/etradeTinService"; +import { EtradeLicensePicker } from "@/shared/components/etrade/EtradeLicensePicker"; + +type VerificationStep = "tin" | "select_license"; + +export default function ComplaintTinVerificationPage() { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const { setUser, setSelectedPositionId } = useAuth(); + const { handleError, getErrorMessage } = useErrorHandler(t); + const [step, setStep] = useState("tin"); + const [tin, setTin] = useState(""); + const [organization, setOrganization] = useState( + null, + ); + const [organizationName, setOrganizationName] = useState(""); + const [licenseOptions, setLicenseOptions] = useState< + EtradeBusinessLicenseOption[] + >([]); + const [selectedLicenseNumber, setSelectedLicenseNumber] = useState(""); + const [error, setError] = useState(null); + const [isVerifying, setIsVerifying] = useState(false); + const [isContinuing, setIsContinuing] = useState(false); + + const handleVerify = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + + const normalizedTin = tin.trim(); + if (!normalizedTin) { + setError(t("complaint.tin.tinRequired")); + return; + } + + if (!/^\d{10}$/.test(normalizedTin)) { + setError(t("complaint.tin.tinInvalid")); + return; + } + + try { + setIsVerifying(true); + const registration = await fetchRegistrationByTin( + normalizedTin, + resolveEtradeLanguage(i18n.language), + ); + const verifiedOrganization = mapEtradeRegistration( + registration, + normalizedTin, + ); + + if (!verifiedOrganization.organizationName && !verifiedOrganization.tin) { + setError(t("complaint.tin.notFound")); + return; + } + + const businesses = mapEtradeBusinessLicenses( + registration, + resolveEtradeLanguage(i18n.language), + ); + + if (businesses.length === 0) { + setError(t("registration.etrade.noLicensesFound")); + return; + } + + setOrganization(verifiedOrganization); + setOrganizationName(verifiedOrganization.organizationName); + setLicenseOptions(businesses); + setSelectedLicenseNumber(businesses[0].licenseNumber); + setStep("select_license"); + } catch (verifyError) { + if (verifyError instanceof TinRegistrationNotFoundError) { + setError(t("complaint.tin.notFound")); + return; + } + if ( + verifyError instanceof Error && + verifyError.message === "ETRADE_REFERER_REJECTED" + ) { + setError(t("complaint.tin.proxyError")); + return; + } + setError(await getErrorMessage(verifyError)); + } finally { + setIsVerifying(false); + } + }; + + const handleLicenseContinue = async () => { + if (!organization || !selectedLicenseNumber) return; + + const selectedLicense = licenseOptions.find( + (option) => option.licenseNumber === selectedLicenseNumber, + ); + if (!selectedLicense) return; + + try { + setIsContinuing(true); + setError(null); + + let phoneNumber: string | undefined; + try { + phoneNumber = + (await resolveEtradePhoneForSignup( + selectedLicense.licenseNumber, + organization.tin, + resolveEtradeLanguage(i18n.language), + )) ?? undefined; + } catch (phoneError) { + console.warn( + "[complaint:eTrade] Failed to resolve phone from GetBusinessByLicenseNo", + phoneError, + ); + } + + if (!phoneNumber) { + setError(t("registration.etrade.phoneLookupFailed")); + return; + } + + const registrationResponse = await registerWithEtrade({ + tin: organization.tin, + licenseNumber: selectedLicense.licenseNumber, + phoneNumber, + }); + const registration = registrationResponse.data; + + if (!registration?.token?.trim()) { + setError(t("complaint.tin.registrationFailed")); + return; + } + + const profile = await persistFaydaRegistrationAuth(registration); + if (profile) { + setUser(profile); + const firstPositionId = + profile.employee?.[0]?.positions?.[0]?.employeePositionId; + if (firstPositionId) { + setSelectedPositionId(firstPositionId); + } + } + + storeComplaintVerification({ + verified: true, + method: "tin", + organization: { + ...organization, + licenseNumber: selectedLicense.licenseNumber, + tradeName: selectedLicense.tradeName, + mainGuid: selectedLicense.mainGuid, + }, + verifiedAt: new Date().toISOString(), + registration, + }); + + navigate(COMPLAINT_RECORDS_PATH, { + replace: true, + state: { fromComplaintVerification: true }, + }); + } catch (continueError) { + handleError(continueError); + } finally { + setIsContinuing(false); + } + }; + + return ( + +
+ + + {t("complaint.back")} + + +
+
+ +
+
+

+ {step === "tin" + ? t("complaint.tin.formTitle") + : t("registration.etrade.selectLicenseTitle")} +

+

+ {step === "tin" + ? t("complaint.tin.formDescription") + : t("registration.etrade.selectLicenseDescription")} +

+
+
+ + {error && ( +
+ +

{error}

+
+ )} + + {step === "tin" ? ( +
+
+ + + setTin(event.target.value.replace(/\D/g, "").slice(0, 10)) + } + placeholder={t("complaint.tin.tinPlaceholder")} + className="font-mono text-lg tracking-wide" + autoComplete="off" + disabled={isVerifying} + required + /> +

+ {t("complaint.tin.tinHint")} +

+
+ + +
+ ) : ( +
+ + +
+ + +
+
+ )} +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/services/complaintVerificationService.ts b/apps/edr-freight-web/backoffice/src/complaints/services/complaintVerificationService.ts new file mode 100644 index 000000000..6ba06a049 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/services/complaintVerificationService.ts @@ -0,0 +1,72 @@ +import type { + ComplaintVerificationResult, + FaydaCallbackData, +} from "../types/complaint.types"; +import { + FaydaOidcError, + registerFaydaCitizenFromCode, +} from "./faydaOidcService"; +import { + fetchRegistrationByTin, + mapEtradeRegistration, + resolveEtradeLanguage, + TinRegistrationNotFoundError, +} from "./etradeTinService"; + +/** + * Authenticates or registers a citizen via FAYDA OIDC using a single flow. + */ +export async function verifyComplaintUser( + callbackData: FaydaCallbackData, + language = "en", +): Promise { + if (!callbackData.code?.trim()) { + throw new Error("Missing authorization code from FAYDA"); + } + + const { registration } = await registerFaydaCitizenFromCode( + callbackData.code, + language, + ); + + return { + verified: true, + method: "fayda", + verifiedAt: new Date().toISOString(), + registration, + }; +} + +/** + * Verifies an organization via the eTrade TIN registration API. + */ +export async function verifyComplaintTin( + tin: string, + language: string, +): Promise { + const normalizedTin = tin.trim(); + if (!/^\d{10}$/.test(normalizedTin)) { + throw new Error("INVALID_TIN"); + } + + const registration = await fetchRegistrationByTin( + normalizedTin, + resolveEtradeLanguage(language), + ); + + const organization = mapEtradeRegistration(registration, normalizedTin); + + if (!organization.organizationName && !organization.tin) { + throw new TinRegistrationNotFoundError(); + } + + return { + verified: true, + method: "tin", + organization, + verifiedAt: new Date().toISOString(), + }; +} + +export { TinRegistrationNotFoundError }; +export { FaydaOidcError }; diff --git a/apps/edr-freight-web/backoffice/src/complaints/services/etradeTinService.ts b/apps/edr-freight-web/backoffice/src/complaints/services/etradeTinService.ts new file mode 100644 index 000000000..3bdc653c4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/services/etradeTinService.ts @@ -0,0 +1,365 @@ +export type EtradeLanguage = "en" | "am"; + +export interface EtradeBusinessSubGroup { + Code?: number; + Description?: string; +} + +export interface EtradeBusiness { + MainGuid?: string; + OwnerTIN?: string; + DateRegistered?: string; + TradeNameAmh?: string; + TradesName?: string; + LicenceNumber?: string; + LicenseNumber?: string; + RenewalDate?: string; + RenewedFrom?: string; + RenewedTo?: string; + SubGroups?: EtradeBusinessSubGroup[]; +} + +export interface EtradeBusinessLicenseOption { + mainGuid: string; + licenseNumber: string; + tradeName: string; + activities: string[]; + renewedTo?: string; +} + +export interface EtradeRegistrationInfo { + Tin?: string; + BusinessName?: string; + BusinessNameAmh?: string; + RegNo?: string; + Businesses?: EtradeBusiness[]; + tin?: string; + businessName?: string; + businessNameAmh?: string; + regNo?: string; + businesses?: EtradeBusiness[]; + [key: string]: unknown; +} + +export class TinRegistrationNotFoundError extends Error { + readonly code = "TIN_NOT_FOUND" as const; + + constructor() { + super("TIN_NOT_FOUND"); + this.name = "TinRegistrationNotFoundError"; + } +} + +const ETRADE_API_BASE = + import.meta.env.VITE_ETRADE_API_BASE?.trim() || "/api/etrade"; + +export function resolveEtradeLanguage(language: string): EtradeLanguage { + return language.toLowerCase().startsWith("am") ? "am" : "en"; +} + +function hasRegistrationData(data: unknown): data is EtradeRegistrationInfo { + if (data == null) return false; + if (typeof data !== "object") return false; + if (Array.isArray(data)) return data.length > 0; + + const record = data as Record; + const keys = Object.keys(record); + if (keys.length === 0) return false; + + const tin = String(record.Tin ?? record.tin ?? "").trim(); + const businessName = String( + record.BusinessName ?? record.businessName ?? "", + ).trim(); + + return Boolean(tin || businessName); +} + +function normalizeEtradeBusinesses( + data: EtradeRegistrationInfo, +): EtradeBusiness[] { + const businesses = data.Businesses ?? data.businesses; + return Array.isArray(businesses) ? businesses : []; +} + +export function mapEtradeBusinessLicenses( + data: EtradeRegistrationInfo, + language: EtradeLanguage, +): EtradeBusinessLicenseOption[] { + const companyName = String( + data.BusinessName ?? data.businessName ?? "", + ).trim(); + const companyNameAmh = String( + data.BusinessNameAmh ?? data.businessNameAmh ?? "", + ).trim(); + + return normalizeEtradeBusinesses(data).flatMap((business) => { + const licenseNumber = String( + business.LicenceNumber ?? business.LicenseNumber ?? "", + ).trim(); + if (!licenseNumber) return []; + + const tradeName = String(business.TradesName ?? "").trim(); + const tradeNameAmh = String(business.TradeNameAmh ?? "").trim(); + const activities = (business.SubGroups ?? []) + .map((group) => String(group.Description ?? "").trim()) + .filter(Boolean); + + const displayTradeName = + language === "am" + ? tradeNameAmh || tradeName || companyNameAmh || companyName + : tradeName || tradeNameAmh || companyName || companyNameAmh; + + const option: EtradeBusinessLicenseOption = { + mainGuid: String(business.MainGuid ?? licenseNumber).trim(), + licenseNumber, + tradeName: displayTradeName || licenseNumber, + activities, + }; + + const renewedTo = String(business.RenewedTo ?? "").trim(); + if (renewedTo) { + option.renewedTo = renewedTo; + } + + return [option]; + }); +} + +export function mapEtradeRegistration( + data: EtradeRegistrationInfo, + fallbackTin: string, +) { + const tin = String(data.Tin ?? data.tin ?? fallbackTin).trim(); + const organizationName = String( + data.BusinessName ?? data.businessName ?? "", + ).trim(); + const regNo = String(data.RegNo ?? data.regNo ?? "").trim(); + + return { + organizationName: organizationName || tin, + tin, + ...(regNo ? { regNo } : {}), + }; +} + +export interface EtradeBusinessByLicense { + MainGuid?: string; + OwnerTIN?: string; + TradeName?: string; + LicenceNumber?: string; + LicenseNumber?: string; + AssociateShortInfos?: Array<{ + Position?: string; + ManagerName?: string; + ManagerNameEng?: string; + MobilePhone?: string; + RegularPhone?: string; + }>; + AddressInfo?: { + Region?: string; + Zone?: string; + Woreda?: string; + Kebele?: string; + HouseNo?: string; + MobilePhone?: string; + RegularPhone?: string; + }; + [key: string]: unknown; +} + +/** + * Normalize Ethiopian phone numbers for IAM auth. + * Accepts 09/07 mobiles and common 0X landline forms; returns +251... + */ +export function normalizeEtradePhoneNumber( + raw: string | null | undefined, +): string | null { + if (!raw?.trim()) return null; + + let digits = raw.trim().replace(/[^\d+]/g, ""); + if (digits.startsWith("+")) { + digits = digits.slice(1); + } + + // Already international without + + if (digits.startsWith("251") && digits.length >= 12) { + return `+${digits}`; + } + + // Local with leading 0 (mobile or landline): 09xxxxxxxx / 07xxxxxxxx / 0Xxxxxxxx + if (digits.startsWith("0") && digits.length >= 9) { + return `+251${digits.slice(1)}`; + } + + // Mobile without leading 0: 9xxxxxxxx / 7xxxxxxxx + if (/^[97]\d{8}$/.test(digits)) { + return `+251${digits}`; + } + + // Bare landline-ish digits (e.g. 52543000) — prefix country code only if 8–9 digits + if (/^\d{8,9}$/.test(digits)) { + return `+251${digits}`; + } + + return null; +} + +function pickRawPhoneFromBusiness(data: EtradeBusinessByLicense): string | null { + const associates = Array.isArray(data.AssociateShortInfos) + ? data.AssociateShortInfos + : []; + + // Prefer RegularPhone — eTrade MobilePhone is often a landline/invalid value + // (e.g. "52543000", "222400000") while RegularPhone holds the mobile (09...). + for (const associate of associates) { + const regular = String(associate.RegularPhone ?? "").trim(); + if (regular) return regular; + } + + const addressRegular = String(data.AddressInfo?.RegularPhone ?? "").trim(); + if (addressRegular) return addressRegular; + + for (const associate of associates) { + const mobile = String(associate.MobilePhone ?? "").trim(); + if (mobile) return mobile; + } + + const addressMobile = String(data.AddressInfo?.MobilePhone ?? "").trim(); + if (addressMobile) return addressMobile; + + return null; +} + +export function extractEtradePhoneNumber( + data: EtradeBusinessByLicense, +): string | null { + return normalizeEtradePhoneNumber(pickRawPhoneFromBusiness(data)); +} + +export async function fetchBusinessByLicenseNo( + licenseNo: string, + tin: string, + language: EtradeLanguage, +): Promise { + const params = new URLSearchParams({ + LicenseNo: licenseNo.trim(), + Tin: tin.trim(), + Lang: language, + }); + const url = `${ETRADE_API_BASE}/BusinessMain/GetBusinessByLicenseNo?${params.toString()}`; + + let response: Response; + try { + response = await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + }, + }); + } catch (error) { + console.error("[eTrade:License] Network request failed", error); + throw error; + } + + const responseText = (await response.text()).trim(); + + if (response.status === 417) { + console.error( + "[eTrade:License] Referer rejected by eTrade. Ensure /api/etrade proxy is configured.", + responseText, + ); + throw new Error("ETRADE_REFERER_REJECTED"); + } + + if (!response.ok && response.status !== 404) { + throw new Error(`ETRADE_HTTP_${response.status}`); + } + + if (!responseText) { + throw new Error("ETRADE_BUSINESS_NOT_FOUND"); + } + + let data: unknown; + try { + data = JSON.parse(responseText); + } catch (parseError) { + console.error( + "[eTrade:License] Invalid JSON response", + parseError, + responseText, + ); + throw new Error("INVALID_ETRADE_RESPONSE"); + } + + if (data == null || typeof data !== "object") { + throw new Error("ETRADE_BUSINESS_NOT_FOUND"); + } + + return data as EtradeBusinessByLicense; +} + +/** + * Looks up business details by license + TIN and returns a normalized phone + * suitable for IAM signup (`+251...`). + */ +export async function resolveEtradePhoneForSignup( + licenseNo: string, + tin: string, + language: EtradeLanguage = "en", +): Promise { + const business = await fetchBusinessByLicenseNo(licenseNo, tin, language); + return extractEtradePhoneNumber(business); +} + +export async function fetchRegistrationByTin( + tin: string, + language: EtradeLanguage, +): Promise { + const normalizedTin = tin.trim(); + const url = `${ETRADE_API_BASE}/Registration/GetRegistrationInfoByTin/${encodeURIComponent(normalizedTin)}/${language}`; + + let response: Response; + try { + response = await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + }, + }); + } catch (error) { + console.error("[eTrade:TIN] Network request failed", error); + throw error; + } + + const responseText = (await response.text()).trim(); + + if (response.status === 417) { + console.error( + "[eTrade:TIN] Referer rejected by eTrade. Ensure /api/etrade proxy is configured.", + responseText, + ); + throw new Error("ETRADE_REFERER_REJECTED"); + } + + if (!response.ok && response.status !== 404) { + throw new Error(`ETRADE_HTTP_${response.status}`); + } + + if (!responseText) { + throw new TinRegistrationNotFoundError(); + } + + let data: unknown; + try { + data = JSON.parse(responseText); + } catch (parseError) { + console.error("[eTrade:TIN] Invalid JSON response", parseError, responseText); + throw new Error("INVALID_ETRADE_RESPONSE"); + } + + if (data == null || !hasRegistrationData(data)) { + throw new TinRegistrationNotFoundError(); + } + + return data; +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/services/faydaOidcService.ts b/apps/edr-freight-web/backoffice/src/complaints/services/faydaOidcService.ts new file mode 100644 index 000000000..5c88d1fd1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/services/faydaOidcService.ts @@ -0,0 +1,75 @@ +import axios from "axios"; +import { + registerWithFayda, + type RegisterWithFaydaResponse, +} from "@/shared/services/authService"; +import { getComplaintFaydaRedirectUri } from "@/shared/utils/faydaOidc"; +import type { VerifiedCitizen } from "../types/complaint.types"; + +export class FaydaOidcError extends Error { + constructor( + readonly code: string, + message?: string, + ) { + super(message ?? code); + this.name = "FaydaOidcError"; + } +} + +export interface FaydaRegistrationResult { + registration: RegisterWithFaydaResponse; + citizen?: VerifiedCitizen; +} + +function mapRegisterWithFaydaError(error: unknown): never { + if (axios.isAxiosError(error)) { + const payload = error.response?.data; + const serverCode = + payload && typeof payload === "object" && "code" in payload + ? String((payload as { code?: string }).code ?? "") + : ""; + + if (serverCode.startsWith("FAYDA_")) { + throw new FaydaOidcError(serverCode); + } + + if (!error.response) { + throw new FaydaOidcError("FAYDA_TOKEN_NETWORK_ERROR"); + } + + const status = error.response.status; + if (status === 400) { + throw new FaydaOidcError("FAYDA_TOKEN_HTTP_400"); + } + if (status === 401) { + throw new FaydaOidcError("FAYDA_TOKEN_HTTP_401"); + } + if (status >= 500) { + throw new FaydaOidcError("FAYDA_TOKEN_HTTP_500"); + } + } + + if (error instanceof FaydaOidcError) { + throw error; + } + + throw new FaydaOidcError("FAYDA_EXCHANGE_FAILED"); +} + +export async function registerFaydaCitizenFromCode( + code: string, + _preferredLocale = "en", +): Promise { + try { + const response = await registerWithFayda({ code }); + const registration = response.data; + + if (!registration?.token?.trim()) { + throw new FaydaOidcError("FAYDA_EXCHANGE_FAILED"); + } + + return { registration }; + } catch (error) { + mapRegisterWithFaydaError(error); + } +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/types/complaint.types.ts b/apps/edr-freight-web/backoffice/src/complaints/types/complaint.types.ts new file mode 100644 index 000000000..e5d1c9701 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/types/complaint.types.ts @@ -0,0 +1,42 @@ +export type ComplaintVerificationMethod = "fayda" | "tin"; + +export interface VerifiedCitizen { + fullName: string; + faydaId: string; +} + +export interface VerifiedOrganization { + organizationName: string; + tin: string; + regNo?: string; + licenseNumber?: string; + tradeName?: string; + mainGuid?: string; +} + +import type { RegisterWithFaydaResponse } from "@/shared/services/authService"; + +export interface ComplaintVerificationResult { + verified: boolean; + method: ComplaintVerificationMethod; + citizen?: VerifiedCitizen; + organization?: VerifiedOrganization; + verifiedAt: string; + registration?: RegisterWithFaydaResponse; +} + +export interface ComplaintVerificationSession extends ComplaintVerificationResult { + verified: true; + method: ComplaintVerificationMethod; +} + +export interface FaydaCallbackData { + code: string; + state?: string | null; +} + +export interface ComplaintFormDraft { + recipient: string; + subject: string; + description: string; +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/utils/complaintRoutes.ts b/apps/edr-freight-web/backoffice/src/complaints/utils/complaintRoutes.ts new file mode 100644 index 000000000..2d6a0fa21 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/utils/complaintRoutes.ts @@ -0,0 +1,4 @@ +export const COMPLAINT_SUBMIT_PATH = + "/external-portal/portal-outgoing/submit-letter"; + +export const COMPLAINT_RECORDS_PATH = "/external-portal/portal-outgoing"; diff --git a/apps/edr-freight-web/backoffice/src/complaints/utils/complaintVerificationStorage.ts b/apps/edr-freight-web/backoffice/src/complaints/utils/complaintVerificationStorage.ts new file mode 100644 index 000000000..f7a500cfc --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/utils/complaintVerificationStorage.ts @@ -0,0 +1,109 @@ +import type { ComplaintVerificationSession } from "../types/complaint.types"; + +const STORAGE_KEY = "complaint-verification"; +const COMPLAINT_VERIFICATION_IDLE_TIMEOUT_MS = 10 * 60 * 1000; + +export function storeComplaintVerification( + session: ComplaintVerificationSession, +): void { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(session)); +} + +export function getComplaintVerification(): ComplaintVerificationSession | null { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return null; + + try { + const session = JSON.parse(raw) as ComplaintVerificationSession; + if (!session.verified) { + return null; + } + + const method = session.method ?? "fayda"; + if (method === "fayda" && !session.citizen) { + return null; + } + if (method === "tin" && !session.organization?.tin) { + return null; + } + + return { ...session, method }; + } catch { + clearComplaintVerification(); + return null; + } +} + +export function clearComplaintVerification(): void { + sessionStorage.removeItem(STORAGE_KEY); +} + +export function hasComplaintVerification(): boolean { + return getComplaintVerification() !== null; +} + +export function isComplaintAuthContext(pathname = ""): boolean { + return ( + pathname.startsWith("/complaints") || + pathname === "/complaint-form" || + pathname === "/follow-complaint" || + pathname === "/callback" + ); +} + +export function setupComplaintVerificationIdleCleanup( + timeoutMs = COMPLAINT_VERIFICATION_IDLE_TIMEOUT_MS, +): () => void { + let timeoutId: number | null = null; + + const scheduleCleanup = () => { + if (timeoutId) { + window.clearTimeout(timeoutId); + } + + timeoutId = window.setTimeout(() => { + clearComplaintVerification(); + }, timeoutMs); + }; + + const handleActivity = () => { + if (!sessionStorage.getItem(STORAGE_KEY)) { + if (timeoutId) { + window.clearTimeout(timeoutId); + timeoutId = null; + } + return; + } + + scheduleCleanup(); + }; + + const events: (keyof WindowEventMap)[] = [ + "mousemove", + "mousedown", + "keydown", + "scroll", + "touchstart", + "click", + "focus", + ]; + + events.forEach((eventName) => { + window.addEventListener(eventName, handleActivity, { passive: true }); + }); + + document.addEventListener("visibilitychange", handleActivity); + handleActivity(); + + return () => { + if (timeoutId) { + window.clearTimeout(timeoutId); + timeoutId = null; + } + + events.forEach((eventName) => { + window.removeEventListener(eventName, handleActivity); + }); + document.removeEventListener("visibilitychange", handleActivity); + }; +} diff --git a/apps/edr-freight-web/backoffice/src/external-portal/components/External-Portal-Navigation/PortalHeader.tsx b/apps/edr-freight-web/backoffice/src/external-portal/components/External-Portal-Navigation/PortalHeader.tsx new file mode 100644 index 000000000..5259dcb7a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/external-portal/components/External-Portal-Navigation/PortalHeader.tsx @@ -0,0 +1,250 @@ +import NotificationList from "@/record-management/components/NotificationList"; +import { useAuthUser } from "@/shared/hooks/useAuthUser"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import { ChevronDown, User, Key, LogOut } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/shared/common/ui/button"; +import { useTranslation } from "react-i18next"; +import { FiBell, FiChevronDown } from "react-icons/fi"; +import { useNavigate } from "react-router-dom"; +import { useNotifications } from "@/shared/hooks/useNotification"; +import { cn } from "@/shared/common/ui/fileUploader/utils"; +import { useUser } from "@/shared/context/UserContext"; +import { + UI_LANGUAGE_OPTIONS, + getUiLanguageLabel, + getUiLanguageShortLabel, + resolveUiLanguage, +} from "@/shared/i18n/uiLanguages"; + +export const ExternalPortal = ({ + mobileView = false, + onItemClick, +}: { + mobileView?: boolean; + onItemClick?: () => void; +}) => { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const userDetails = useUser(); + const { logout } = useAuthUser(); + const fullName = userDetails?.name?.en || t("header.user"); + const splittedName = fullName.trim().split(" "); + const initials = + splittedName.length === 1 + ? splittedName[0][0] + : `${splittedName[0][0]}${splittedName[1][0]}`; + + const currentLanguage = resolveUiLanguage(i18n.language); + const changeLanguage = (lng: string) => i18n.changeLanguage(lng); + const handleLogout = () => { + logout("/external-portal/signin"); + }; + + const { unseenCount } = useNotifications({ + take: 10, + skip: 0, + orderBy: "updatedAt:DESC", + }); + const [openNotifications, setOpenNotifications] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setOpenNotifications(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + // Handle navigation with optional callback for mobile + const handleNavigation = (path: string) => { + navigate(path); + if (onItemClick) onItemClick(); + }; + + return ( + <> +
+ {/* Notifications */} + +
+ + {openNotifications && ( +
+ +
+ )} +
+ {/* Language Switcher */} + + + + + + {UI_LANGUAGE_OPTIONS.map((lang) => ( + { + changeLanguage(lang.value); + if (onItemClick) onItemClick(); + }} + className={cn( + "flex items-center justify-between text-sm text-gray-700 hover:bg-primary-100 rounded-md px-3 py-2 cursor-pointer", + currentLanguage === lang.value && "bg-primary-50", + )} + > + {getUiLanguageLabel(lang.value, t)} + {currentLanguage === lang.value && ( + + {getUiLanguageShortLabel(lang.value, t)} + + )} + + ))} + + + {/* User Menu */} + + + + + + handleNavigation("/profile")}> + + {t("header.viewProfile")} + + + handleNavigation("/record-management/change-password") + }> + + {t("header.changePassword")} + + + + + {t("header.signOut")} + + + +
+ + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/external-portal/components/External-Portal-Navigation/VerificationPending.tsx b/apps/edr-freight-web/backoffice/src/external-portal/components/External-Portal-Navigation/VerificationPending.tsx new file mode 100644 index 000000000..52de124c3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/external-portal/components/External-Portal-Navigation/VerificationPending.tsx @@ -0,0 +1,49 @@ +"use client"; +import Header from "../../../layout/components/Header"; +import React from "react"; +import { AlertCircle, Mail, PhoneCall } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +interface VerificationPendingProps { + hasCompletedRegistration: boolean; + contactNumber?: string; + email?:string; + +} + +const VerificationPending: React.FC = ({ + hasCompletedRegistration, + contactNumber, + email, +}) => { + const { t } = useTranslation(); + + if (hasCompletedRegistration) { + return null; // ✅ Nothing to show if user has finished registration + } + + return ( +
+
+ +
+
+ +

+ {t("verification.pending")} +

+
+ +

+ {t("verification.message")}{" "} + + {email} or {contactNumber} . +

+ + +
+
+ ); +}; + +export default VerificationPending; diff --git a/apps/edr-freight-web/backoffice/src/external-portal/components/Registration/ExternalAuthPage.tsx b/apps/edr-freight-web/backoffice/src/external-portal/components/Registration/ExternalAuthPage.tsx new file mode 100644 index 000000000..46c4ff381 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/external-portal/components/Registration/ExternalAuthPage.tsx @@ -0,0 +1,5 @@ +import UserTypeSelection from "./UserTypeSelection"; + +export default function ExternalAuthPage() { + return ; +} diff --git a/apps/edr-freight-web/backoffice/src/external-portal/components/Registration/ExternalPortalCallBack.tsx b/apps/edr-freight-web/backoffice/src/external-portal/components/Registration/ExternalPortalCallBack.tsx new file mode 100644 index 000000000..e811c5b59 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/external-portal/components/Registration/ExternalPortalCallBack.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useRegisterExternalPortalUser } from "@/external-portal/hooks/useRegisterExternalPortalUser"; +import { Button } from "@/shared/common/ui/button"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; +import { getDefaultFaydaRedirectUri } from "@/shared/utils/faydaOidc"; +import { persistFaydaRegistrationAuth } from "@/shared/utils/faydaAuthSession"; +import { Loader2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { toast } from "sonner"; + +export default function ExternalPortalCallback() { + const { t } = useTranslation(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { handleError } = useErrorHandler(t); + const { isFayidaRegistering, registerExternalFayidaUser } = + useRegisterExternalPortalUser(); + const hasRun = useRef(false); + + useEffect(() => { + if (hasRun.current) return; + hasRun.current = true; + + const code = searchParams.get("code"); + const state = searchParams.get("state"); + + if (!code) { + toast.error("Missing authorization code"); + setError("Missing authorization code"); + setLoading(false); + navigate("/external-portal/signin", { replace: true }); + return; + } + + const loginWithFayda = async () => { + try { + const res = await registerExternalFayidaUser({ + code, + redirectUri: getDefaultFaydaRedirectUri(), + }); + + if (!res || !res.response?.data) { + throw new Error("Invalid response from Fayda login"); + } + + const data = res.response.data; + const userId = data?.userId ?? data?.user?.id ?? data?.user?.userId; + const token = data?.token; + const refreshToken = data?.refreshToken; + + if (!userId) { + throw new Error("Failed to get user ID from server."); + } + + await persistFaydaRegistrationAuth({ + ...data, + token, + refreshToken, + }); + + navigate(`/verify-otp?userId=${userId}&isExternalOrg=false`, { + replace: true, + }); + } catch (err: unknown) { + console.error("LoginWithFayda error:", err); + handleError(err); + setError( + err instanceof Error + ? err.message + : t("registration.auth.faydaSigninFailed"), + ); + } finally { + setLoading(false); + } + }; + + loginWithFayda(); + }, [searchParams, navigate, handleError, registerExternalFayidaUser, t]); + + if (loading || isFayidaRegistering) { + return ( +
+ +

+ {t("registration.auth.faydaProcessing")} +

+
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + return null; +} diff --git a/apps/edr-freight-web/backoffice/src/external-portal/components/Registration/FilePreview.tsx b/apps/edr-freight-web/backoffice/src/external-portal/components/Registration/FilePreview.tsx new file mode 100644 index 000000000..a0049dcb1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/external-portal/components/Registration/FilePreview.tsx @@ -0,0 +1,249 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shared/common/ui/dialog"; +import { Button } from "@/shared/common/ui/button"; +import { + FileText, + ImageIcon, + FileIcon, + VideoIcon, + DownloadIcon, + XIcon, + Loader2, +} from "lucide-react"; +import { cn } from "@/shared/lib/utils"; +import { useToast } from "@/shared/common/ui/use-toast"; + +type FileType = "image" | "pdf" | "video" | "other"; + +interface FilePreviewProps { + file: File | string; // Can accept File object or URL string + type?: FileType; // Optional type hint + className?: string; + onRemove?: () => void; + showDownload?: boolean; +} + +export const FilePreview = ({ + file, + type, + className, + onRemove, + showDownload = true, +}: FilePreviewProps) => { + const [previewUrl, setPreviewUrl] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [detectedType, setDetectedType] = useState("other"); + const { toast } = useToast(); + + useEffect(() => { + const determineFileType = (): FileType => { + if (type) return type; + + if (typeof file === "string") { + const extension = file.split(".").pop()?.toLowerCase(); + if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension || "")) { + return "image"; + } + if (extension === "pdf") return "pdf"; + if (["mp4", "webm", "ogg"].includes(extension || "")) return "video"; + return "other"; + } + + if (file.type.startsWith("image/")) return "image"; + if (file.type === "application/pdf") return "pdf"; + if (file.type.startsWith("video/")) return "video"; + return "other"; + }; + + const generatePreview = async () => { + setIsLoading(true); + setDetectedType(determineFileType()); + + try { + if (typeof file === "string") { + setPreviewUrl(file); + } else { + const url = URL.createObjectURL(file); + setPreviewUrl(url); + } + } catch (error) { + console.error("Error generating preview:", error); + toast({ + title: "Error", + description: "Could not generate file preview", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + generatePreview(); + + return () => { + if (previewUrl && typeof file !== "string") { + URL.revokeObjectURL(previewUrl); + } + }; + }, [file, type]); + + const handleDownload = () => { + if (!previewUrl) return; + + const link = document.createElement("a"); + link.href = previewUrl; + link.download = typeof file === "string" ? "download" : file.name; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const renderPreview = () => { + if (isLoading) { + return ( +
+ +
+ ); + } + + switch (detectedType) { + case "image": + return ( + Preview setIsLoading(false)} + /> + ); + case "pdf": + return ( +
+ + + {typeof file === "string" ? "PDF Document" : file.name} + +
+ ); + case "video": + return ( + + ); + default: + return ( +
+ + + {typeof file === "string" ? "File" : file.name} + +
+ ); + } + }; + + return ( + <> +
+ {renderPreview()} + +
+ {onRemove && ( + + )} + {showDownload && previewUrl && ( + + )} +
+ + +
+ + + + + + {typeof file === "string" ? "File Preview" : file.name} + + +
+ {detectedType === "image" && ( + Fullscreen preview + )} + {detectedType === "pdf" && ( +