mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 06:35:42 +00:00
fix ui
This commit is contained in:
@@ -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 (
|
||||
<div>
|
||||
<Header />
|
||||
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white flex flex-col pt-16 md:pt-20">
|
||||
<div className="bg-primary py-12 md:py-16 text-center text-white relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-10" />
|
||||
<h1 className="text-3xl md:text-5xl font-bold mb-3 relative z-10">
|
||||
{heroTitle}
|
||||
</h1>
|
||||
{heroSubtitle && (
|
||||
<p className="max-w-2xl mx-auto text-base md:text-lg opacity-90 relative z-10 px-4">
|
||||
{heroSubtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 flex justify-center p-4 md:p-6">{children}</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <Navigate to="/complaints" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="mb-6 rounded-xl border border-emerald-200 bg-emerald-50/80 p-5 md:p-6">
|
||||
<h3 className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-emerald-800">
|
||||
<BadgeCheck className="h-4 w-4" />
|
||||
{isTinComplaint
|
||||
? t("complaint.tin.verifiedInfo")
|
||||
: t("complaint.fayda.verifiedInfo")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{isTinComplaint ? (
|
||||
<>
|
||||
<PortalReadOnlyField
|
||||
label={t("complaint.tin.organizationName")}
|
||||
value={session.organization?.organizationName}
|
||||
icon={Building2}
|
||||
/>
|
||||
<PortalReadOnlyField
|
||||
label={t("complaint.tin.tinLabel")}
|
||||
value={session.organization?.tin}
|
||||
icon={IdCard}
|
||||
mono
|
||||
/>
|
||||
{session.organization?.tradeName ? (
|
||||
<PortalReadOnlyField
|
||||
label={t("complaint.tin.tradeName")}
|
||||
value={session.organization.tradeName}
|
||||
icon={Building2}
|
||||
/>
|
||||
) : null}
|
||||
{session.organization?.licenseNumber ? (
|
||||
<PortalReadOnlyField
|
||||
label={t("complaint.tin.licenseNumber")}
|
||||
value={session.organization.licenseNumber}
|
||||
icon={IdCard}
|
||||
mono
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PortalReadOnlyField
|
||||
label={t("complaint.fullName")}
|
||||
value={session.citizen?.fullName}
|
||||
icon={User}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-red-50 px-4">
|
||||
<p className="text-red-600 mb-4 text-center max-w-md">{error}</p>
|
||||
<Button
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/complaints?error=${encodeURIComponent(error)}`,
|
||||
{ replace: true },
|
||||
)
|
||||
}>
|
||||
{t("complaint.fayda.backToComplaints")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-white">
|
||||
<Loader2 className="h-10 w-10 text-primary animate-spin mb-4" />
|
||||
<p className="text-primary text-lg font-medium text-center px-4">
|
||||
{t("complaint.fayda.verifying")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <Navigate to="/complaints" replace />;
|
||||
}
|
||||
|
||||
return <Navigate to={COMPLAINT_RECORDS_PATH} replace />;
|
||||
}
|
||||
@@ -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 (
|
||||
<ComplaintPageLayout
|
||||
heroTitle={t("complaint.choice.title")}
|
||||
heroSubtitle={t("complaint.choice.subtitle")}>
|
||||
<div className="w-full max-w-3xl space-y-8">
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-500" />
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-center text-sm text-gray-600">
|
||||
{t("complaint.choice.prompt")}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{authOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
key={option.provider}
|
||||
type="button"
|
||||
onClick={option.onClick}
|
||||
className="group rounded-2xl border border-gray-200 bg-white p-6 text-left transition hover:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary transition group-hover:bg-primary group-hover:text-white">
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{option.title}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-gray-600">
|
||||
{option.description}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100 pt-6 text-center">
|
||||
<Link
|
||||
to="/follow-complaint"
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline">
|
||||
<Search className="h-4 w-4" />
|
||||
{t("complaint.followTitle")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</ComplaintPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ComplaintPageLayout
|
||||
heroTitle={t("complaint.success")}
|
||||
heroSubtitle={t("complaint.fayda.successSubtitle")}>
|
||||
<div className="w-full max-w-lg bg-white shadow-2xl rounded-2xl p-8 border border-gray-100 text-center">
|
||||
<CheckCircle className="mx-auto mb-4 h-16 w-16 text-primary" />
|
||||
<h2 className="mb-2 text-xl font-semibold text-gray-900">
|
||||
{t("complaint.fayda.successMessage")}
|
||||
</h2>
|
||||
<p className="mb-6 text-gray-600">
|
||||
{t("complaint.fayda.successNote")}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button asChild onClick={handleDone}>
|
||||
<Link to="/">{t("complaint.fayda.backToHome")}</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" onClick={handleDone}>
|
||||
<Link
|
||||
to="/follow-complaint"
|
||||
className="inline-flex items-center gap-2">
|
||||
<Search className="h-4 w-4" />
|
||||
{t("complaint.followTitle")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ComplaintPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -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<VerificationStep>("tin");
|
||||
const [tin, setTin] = useState("");
|
||||
const [organization, setOrganization] = useState<VerifiedOrganization | null>(
|
||||
null,
|
||||
);
|
||||
const [organizationName, setOrganizationName] = useState("");
|
||||
const [licenseOptions, setLicenseOptions] = useState<
|
||||
EtradeBusinessLicenseOption[]
|
||||
>([]);
|
||||
const [selectedLicenseNumber, setSelectedLicenseNumber] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [isContinuing, setIsContinuing] = useState(false);
|
||||
|
||||
const handleVerify = async (event: FormEvent<HTMLFormElement>) => {
|
||||
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 (
|
||||
<ComplaintPageLayout
|
||||
heroTitle={t("registration.auth.continueWithEtrade")}
|
||||
heroSubtitle={t("complaint.tin.subtitle")}>
|
||||
<div className="w-full max-w-xl rounded-2xl border border-gray-200 bg-white p-6 md:p-8">
|
||||
<Link
|
||||
to="/complaints"
|
||||
className="mb-6 inline-flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-primary">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("complaint.back")}
|
||||
</Link>
|
||||
|
||||
<div className="mb-6 flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-amber-50 text-amber-600">
|
||||
<Building2 className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{step === "tin"
|
||||
? t("complaint.tin.formTitle")
|
||||
: t("registration.etrade.selectLicenseTitle")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
{step === "tin"
|
||||
? t("complaint.tin.formDescription")
|
||||
: t("registration.etrade.selectLicenseDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-500" />
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "tin" ? (
|
||||
<form onSubmit={handleVerify} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tin">{t("complaint.tin.tinLabel")}</Label>
|
||||
<Input
|
||||
id="tin"
|
||||
inputMode="numeric"
|
||||
pattern="\d*"
|
||||
maxLength={10}
|
||||
value={tin}
|
||||
onChange={(event) =>
|
||||
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
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t("complaint.tin.tinHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isVerifying || tin.trim().length !== 10}
|
||||
className="w-full py-6 text-base font-semibold">
|
||||
{isVerifying ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
{t("complaint.tin.verifying")}
|
||||
</span>
|
||||
) : (
|
||||
t("complaint.tin.verify")
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<EtradeLicensePicker
|
||||
organizationName={organizationName}
|
||||
options={licenseOptions}
|
||||
selectedLicenseNumber={selectedLicenseNumber}
|
||||
onSelect={setSelectedLicenseNumber}
|
||||
/>
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setStep("tin");
|
||||
setSelectedLicenseNumber("");
|
||||
setLicenseOptions([]);
|
||||
setOrganization(null);
|
||||
setOrganizationName("");
|
||||
}}
|
||||
disabled={isContinuing}>
|
||||
{t("registration.etrade.back")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleLicenseContinue}
|
||||
disabled={!selectedLicenseNumber || isContinuing}
|
||||
className="min-w-[8rem]">
|
||||
{isContinuing ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("registration.etrade.loading")}
|
||||
</span>
|
||||
) : (
|
||||
t("registration.etrade.continue")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ComplaintPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -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<ComplaintVerificationResult> {
|
||||
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<ComplaintVerificationResult> {
|
||||
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 };
|
||||
@@ -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<string, unknown>;
|
||||
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<EtradeBusinessByLicense> {
|
||||
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<string | null> {
|
||||
const business = await fetchBusinessByLicenseNo(licenseNo, tin, language);
|
||||
return extractEtradePhoneNumber(business);
|
||||
}
|
||||
|
||||
export async function fetchRegistrationByTin(
|
||||
tin: string,
|
||||
language: EtradeLanguage,
|
||||
): Promise<EtradeRegistrationInfo> {
|
||||
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;
|
||||
}
|
||||
@@ -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<FaydaRegistrationResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const COMPLAINT_SUBMIT_PATH =
|
||||
"/external-portal/portal-outgoing/submit-letter";
|
||||
|
||||
export const COMPLAINT_RECORDS_PATH = "/external-portal/portal-outgoing";
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user