Files
edr-platform/apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx
2026-08-15 08:10:47 +00:00

228 lines
6.7 KiB
TypeScript

import { useState, type ReactNode } from "react";
import {
Alert,
Avatar,
Badge,
Button,
Card,
Group,
Stack,
Text,
} from "@mantine/core";
import {
BadgeCheck,
Clock,
Mail,
MapPin,
Phone,
ShieldCheck,
XCircle,
} from "lucide-react";
import {
stashPendingVerification,
verifaydaService,
type IdentitySubject,
type IdentityVerificationState,
} from "@/services/verifayda.service";
import { isBypassEnv } from "@/utils/dev-bypass";
/** Sentinel code that skips the real eSignet exchange in dev/staging (see api's DEV_BYPASS_FAYDA_CODE). */
const DEV_BYPASS_FAYDA_CODE = "DEV_BYPASS";
interface FaydaVerifyPanelProps {
subject: IdentitySubject;
/** Heading — "General Manager" / "Power of Attorney". */
title: string;
/** What this person's verification is currently known to be. */
state?: IdentityVerificationState;
/**
* False for a foreign company: verification is offered but nothing is gated
* on it, so the panel says so rather than nagging.
*/
required: boolean;
disabled?: boolean;
/**
* True when a fresh verification for this person is already staged in a
* pending change request. On an active company a re-verification never
* touches the live record — it's staged for review — so `state` alone
* would keep showing the OLD verified data with no sign anything happened.
*/
pendingReview?: boolean;
}
function getInitials(name: string | null): string {
if (!name) return "?";
const parts = name.trim().split(/\s+/);
const first = parts[0]?.[0] ?? "";
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : "";
return (first + last).toUpperCase();
}
/**
* Verify one of the company's people through Fayda and show what came back.
*
* The identity is proved on eSignet, which the whole tab navigates to — no
* popup, because a popup opened after the /start round-trip has lost its user
* activation and iOS Safari blocks it outright. eSignet redirects back to
* /fayda/callback, which completes the exchange and returns the user here; the API
* writes the person's name, phone, email and address from the verified
* payload. Nothing on this panel is typed.
*/
export default function FaydaVerifyPanel({
subject,
title,
state,
required,
disabled,
pendingReview,
}: FaydaVerifyPanelProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const startVerification = async () => {
setError(null);
setLoading(true);
try {
// Dev/staging only: send the tab straight to /fayda/callback with the
// sentinel code instead of round-tripping through eSignet — that page's
// existing completeIdentity()/navigate-back logic runs unchanged.
if (isBypassEnv()) {
stashPendingVerification({
subject,
returnTo:
window.location.pathname +
window.location.search +
window.location.hash,
});
window.location.assign(
`/fayda/callback?code=${DEV_BYPASS_FAYDA_CODE}&state=bypass`,
);
return;
}
const authorizationUrl = await verifaydaService.start();
// Record who is being verified and where to come back to before the tab
// leaves — /fayda/callback has no other way to know either.
stashPendingVerification({
subject,
returnTo:
window.location.pathname +
window.location.search +
window.location.hash,
});
window.location.assign(authorizationUrl);
} catch (err) {
setLoading(false);
setError(
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
(err instanceof Error ? err.message : "Could not start verification"),
);
}
};
const verified = state?.verified ?? false;
return (
<Card padding="md" radius="md" withBorder>
<Group justify="space-between" align="center" mb={verified ? "md" : "xs"}>
<Group gap="sm">
<ShieldCheck size={18} />
<Text fw={600} c="edr-text">
{title}
</Text>
{verified ? (
<Badge
size="sm"
variant="light"
color="green"
leftSection={<BadgeCheck size={11} />}
>
Fayda verified
</Badge>
) : (
required && (
<Badge size="sm" variant="light" color="amber">
Verification required
</Badge>
)
)}
{pendingReview && (
<Badge
size="sm"
variant="light"
color="amber"
leftSection={<Clock size={11} />}
>
Re-verification pending review
</Badge>
)}
</Group>
<Button
type="button"
variant="light"
size="xs"
loading={loading}
disabled={disabled}
onClick={startVerification}
>
{verified ? "Re-verify with Fayda" : "Verify with Fayda"}
</Button>
</Group>
{!verified && (
<Text c="edr-muted" size="xs">
{required
? "Verify this person with Fayda. Their name, phone and address come from the verification — there is nothing to fill in by hand."
: "Optional for a foreign company. If this person holds a Fayda ID, verifying it fills in their details."}
</Text>
)}
{verified && state && (
<Group align="flex-start" gap="sm" wrap="nowrap">
<Avatar radius="xl" size={44} color="edr-green" variant="light">
{getInitials(state.name)}
</Avatar>
<Stack gap={4} style={{ minWidth: 0, flex: 1 }}>
<Text fw={600} size="sm" c="edr-text" truncate>
{state.name}
</Text>
<Group gap="md" wrap="wrap">
<DataRow icon={<Phone size={13} />} value={state.phone} />
<DataRow icon={<Mail size={13} />} value={state.email} />
</Group>
<DataRow icon={<MapPin size={13} />} value={state.address} />
</Stack>
</Group>
)}
{error && (
<Alert mt="sm" color="red" variant="light" icon={<XCircle size={18} />}>
{error}
</Alert>
)}
</Card>
);
}
function DataRow({ icon, value }: { icon: ReactNode; value: string | null }) {
if (!value) return null;
return (
<Group gap={6} wrap="nowrap">
<span
style={{
color: "var(--mantine-color-edr-muted-6)",
display: "flex",
flexShrink: 0,
}}
>
{icon}
</span>
<Text size="xs" c="edr-muted" truncate>
{value}
</Text>
</Group>
);
}