mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
feat(freight-portal): verify Fayda via redirect, gate DARS on verification
The verification popup was opened after the /start round-trip, by which
point the click's user activation is spent — iOS Safari blocks it outright,
so mobile customers could never verify. Replace the popup with a full-page
redirect: the panel stashes {subject, returnTo} in sessionStorage and
navigates the tab to eSignet, and /callback completes the code+state
exchange itself before returning the user where they were.
This drops the postMessage listener, the popup-closed poller and the
pop-up-blocked branch. onVerified goes with them: the app boots fresh on
the way back, so the target page refetches rather than being pushed to.
Hide the DARS delegation upload until the PoA is Fayda-verified. The paper
authorises the representative the verification names, so it has nothing to
authorise before one exists — and it has to stop being required while
hidden, or the save blocks on a control the customer cannot see. Freight
forwarders are still held to having a PoA by the step's verification gate
and by the API. This also removes the one thing the redirect could not
carry across: a staged File cannot be serialised to sessionStorage, and
there is now never one pending before verification.
Unsaved text typed since the last step-save is still lost on redirect; the
wizard's per-step persistence covers everything already advanced past.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -20,9 +20,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
stashPendingVerification,
|
||||||
verifaydaService,
|
verifaydaService,
|
||||||
type CompanyIdentityState,
|
|
||||||
type FaydaCallbackMessage,
|
|
||||||
type IdentitySubject,
|
type IdentitySubject,
|
||||||
type IdentityVerificationState,
|
type IdentityVerificationState,
|
||||||
} from "@/services/verifayda.service";
|
} from "@/services/verifayda.service";
|
||||||
@@ -38,8 +37,6 @@ interface FaydaVerifyPanelProps {
|
|||||||
* on it, so the panel says so rather than nagging.
|
* on it, so the panel says so rather than nagging.
|
||||||
*/
|
*/
|
||||||
required: boolean;
|
required: boolean;
|
||||||
/** Called with the fresh company-wide state once a verification lands. */
|
|
||||||
onVerified: (next: CompanyIdentityState) => void;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/**
|
/**
|
||||||
* True when a fresh verification for this person is already staged in a
|
* True when a fresh verification for this person is already staged in a
|
||||||
@@ -61,109 +58,39 @@ function getInitials(name: string | null): string {
|
|||||||
/**
|
/**
|
||||||
* Verify one of the company's people through Fayda and show what came back.
|
* Verify one of the company's people through Fayda and show what came back.
|
||||||
*
|
*
|
||||||
* The identity is proved in an eSignet popup; that popup lands on /callback,
|
* The identity is proved on eSignet, which the whole tab navigates to — no
|
||||||
* which relays the code+state here by postMessage. This window then completes
|
* popup, because a popup opened after the /start round-trip has lost its user
|
||||||
* the exchange — once, in one place — and the API writes the person's name,
|
* activation and iOS Safari blocks it outright. eSignet redirects back to
|
||||||
* phone, email and address from the verified payload. Nothing on this panel
|
* /callback, which completes the exchange and returns the user here; the API
|
||||||
* is typed.
|
* writes the person's name, phone, email and address from the verified
|
||||||
|
* payload. Nothing on this panel is typed.
|
||||||
*/
|
*/
|
||||||
export default function FaydaVerifyPanel({
|
export default function FaydaVerifyPanel({
|
||||||
subject,
|
subject,
|
||||||
title,
|
title,
|
||||||
state,
|
state,
|
||||||
required,
|
required,
|
||||||
onVerified,
|
|
||||||
disabled,
|
disabled,
|
||||||
pendingReview,
|
pendingReview,
|
||||||
}: FaydaVerifyPanelProps) {
|
}: FaydaVerifyPanelProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// The listener closes over `subject`; keep it in a ref so remounting the
|
|
||||||
// panel between steps can't complete a verification against the wrong person.
|
|
||||||
const subjectRef = useRef(subject);
|
|
||||||
subjectRef.current = subject;
|
|
||||||
// FaydaCallbackPage posts its message from a StrictMode-double-invoked
|
|
||||||
// effect in dev, so the same one-time-use code+state can arrive twice.
|
|
||||||
// Track the last state we've started completing so the resend is a no-op.
|
|
||||||
const handledStateRef = useRef<string | null>(null);
|
|
||||||
// Polls the popup so a manually-closed window (no postMessage ever sent)
|
|
||||||
// still clears `loading` instead of leaving the button spinning forever.
|
|
||||||
const pollRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const stopPolling = () => {
|
|
||||||
if (pollRef.current !== null) {
|
|
||||||
window.clearInterval(pollRef.current);
|
|
||||||
pollRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onMessage = async (event: MessageEvent<FaydaCallbackMessage>) => {
|
|
||||||
if (event.origin !== window.location.origin) return;
|
|
||||||
if (event.data?.type !== "fayda-callback") return;
|
|
||||||
|
|
||||||
if (event.data.error) {
|
|
||||||
stopPolling();
|
|
||||||
setLoading(false);
|
|
||||||
setError(event.data.errorDescription ?? event.data.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!event.data.code || !event.data.state) return;
|
|
||||||
if (handledStateRef.current === event.data.state) return;
|
|
||||||
handledStateRef.current = event.data.state;
|
|
||||||
stopPolling();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const next = await verifaydaService.completeIdentity(
|
|
||||||
subjectRef.current,
|
|
||||||
event.data.code,
|
|
||||||
event.data.state,
|
|
||||||
);
|
|
||||||
setError(null);
|
|
||||||
onVerified(next);
|
|
||||||
} catch (err) {
|
|
||||||
setError(
|
|
||||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
|
||||||
?.message ??
|
|
||||||
(err instanceof Error ? err.message : "Verification failed"),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("message", onMessage);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("message", onMessage);
|
|
||||||
stopPolling();
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startVerification = async () => {
|
const startVerification = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
handledStateRef.current = null;
|
|
||||||
try {
|
try {
|
||||||
const authorizationUrl = await verifaydaService.start();
|
const authorizationUrl = await verifaydaService.start();
|
||||||
const popup = window.open(
|
// Record who is being verified and where to come back to before the tab
|
||||||
authorizationUrl,
|
// leaves — /callback has no other way to know either.
|
||||||
"fayda-verify",
|
stashPendingVerification({
|
||||||
"width=480,height=760,noopener=no",
|
subject,
|
||||||
);
|
returnTo:
|
||||||
if (!popup) {
|
window.location.pathname +
|
||||||
setLoading(false);
|
window.location.search +
|
||||||
setError("Pop-up blocked — allow pop-ups for this site and try again.");
|
window.location.hash,
|
||||||
return;
|
});
|
||||||
}
|
window.location.assign(authorizationUrl);
|
||||||
// Loading stays on until the popup posts back — unless the user closes
|
|
||||||
// it by hand, which never sends a message; poll for that and clear
|
|
||||||
// loading ourselves so the button doesn't spin forever.
|
|
||||||
stopPolling();
|
|
||||||
pollRef.current = window.setInterval(() => {
|
|
||||||
if (!popup.closed) return;
|
|
||||||
stopPolling();
|
|
||||||
if (handledStateRef.current === null) setLoading(false);
|
|
||||||
}, 500);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError(
|
setError(
|
||||||
|
|||||||
@@ -437,10 +437,6 @@ export default function OnboardingWizardDialog({
|
|||||||
// stays a plain typed role. Mandatory (Fayda) for an Ethiopian company;
|
// stays a plain typed role. Mandatory (Fayda) for an Ethiopian company;
|
||||||
// a foreign one requires a typed passport number for the owner instead.
|
// a foreign one requires a typed passport number for the owner instead.
|
||||||
identity: requirementsQuery.data?.identity,
|
identity: requirementsQuery.data?.identity,
|
||||||
onIdentityChange: () => {
|
|
||||||
void profileQuery.refetch();
|
|
||||||
void requirementsQuery.refetch();
|
|
||||||
},
|
|
||||||
// Surface a failed final submit (license/document upload or complete) inside
|
// Surface a failed final submit (license/document upload or complete) inside
|
||||||
// the form — otherwise the server message (e.g. a 500) would be invisible on
|
// the form — otherwise the server message (e.g. a 500) would be invisible on
|
||||||
// the submit step.
|
// the submit step.
|
||||||
|
|||||||
@@ -1,51 +1,89 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Center, Loader, Stack, Text } from "@mantine/core";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Button, Center, Loader, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
import type { FaydaCallbackMessage } from "@/services/verifayda.service";
|
import {
|
||||||
|
takePendingVerification,
|
||||||
|
verifaydaService,
|
||||||
|
} from "@/services/verifayda.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Landing page for the portal's eSignet redirect_uri
|
* Landing page for the portal's eSignet redirect_uri
|
||||||
* (FAYDA_PORTAL_REDIRECT_URI → http://localhost:5173/callback). Runs inside the
|
* (FAYDA_PORTAL_REDIRECT_URI → http://localhost:5173/callback).
|
||||||
* verification popup: relays ?code&state (or ?error) to the window that opened
|
*
|
||||||
* it via postMessage, then closes itself. The opener performs the completion
|
* The verification is a full-page redirect, so the page that started it no
|
||||||
* call so the single-use session is only consumed once, in one place.
|
* longer exists: this page completes the code+state exchange itself against
|
||||||
|
* the subject FaydaVerifyPanel stashed, then sends the user back where they
|
||||||
|
* were. Everything mounts fresh on the way back, so the verified identity is
|
||||||
|
* fetched rather than pushed.
|
||||||
*/
|
*/
|
||||||
export default function FaydaCallbackPage() {
|
export default function FaydaCallbackPage() {
|
||||||
const [standalone, setStandalone] = useState(false);
|
const navigate = useNavigate();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [returnTo, setReturnTo] = useState("/");
|
||||||
|
// The code+state are single-use, so StrictMode's double-invoked effect must
|
||||||
|
// not exchange them twice — the second attempt would fail on a spent session.
|
||||||
|
const startedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
if (startedRef.current) return;
|
||||||
const message: FaydaCallbackMessage = {
|
startedRef.current = true;
|
||||||
type: "fayda-callback",
|
|
||||||
code: params.get("code") ?? undefined,
|
|
||||||
state: params.get("state") ?? undefined,
|
|
||||||
error: params.get("error") ?? undefined,
|
|
||||||
errorDescription: params.get("error_description") ?? undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (window.opener && window.opener !== window) {
|
const params = new URLSearchParams(window.location.search);
|
||||||
(window.opener as Window).postMessage(message, window.location.origin);
|
const pending = takePendingVerification();
|
||||||
window.close();
|
if (pending) setReturnTo(pending.returnTo);
|
||||||
} else {
|
|
||||||
// Opened as a full-page redirect instead of a popup — nothing to relay to.
|
const authError = params.get("error");
|
||||||
setStandalone(true);
|
if (authError) {
|
||||||
|
setError(params.get("error_description") ?? authError);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, []);
|
|
||||||
|
const code = params.get("code");
|
||||||
|
const state = params.get("state");
|
||||||
|
if (!code || !state) {
|
||||||
|
setError("This verification link is missing its code — start again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pending) {
|
||||||
|
// Landed here without the tab that started it — a bookmarked/copied
|
||||||
|
// callback URL, or sessionStorage cleared mid-flow.
|
||||||
|
setError("This verification was started somewhere else — start again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
verifaydaService
|
||||||
|
.completeIdentity(pending.subject, code, state)
|
||||||
|
.then(() => navigate(pending.returnTo, { replace: true }))
|
||||||
|
.catch((err) =>
|
||||||
|
setError(
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response
|
||||||
|
?.data?.message ??
|
||||||
|
(err instanceof Error ? err.message : "Verification failed"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Center h="100vh">
|
<Center h="100vh">
|
||||||
<Stack align="center" gap="sm">
|
<Stack align="center" gap="sm">
|
||||||
{standalone ? (
|
{error ? (
|
||||||
<>
|
<>
|
||||||
<Text fw={600}>Verification window lost its parent page</Text>
|
<Text fw={600}>Verification could not be completed</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="edr-muted" ta="center" maw={360}>
|
||||||
Close this tab and start the verification again from the form.
|
{error}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
onClick={() => navigate(returnTo, { replace: true })}
|
||||||
|
>
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Loader size="sm" color="edr-green" />
|
<Loader size="sm" color="edr-green" />
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="edr-muted">
|
||||||
Completing Fayda verification…
|
Completing Fayda verification…
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ export default function CompanyProfileForm({
|
|||||||
uploadedDocumentKeys,
|
uploadedDocumentKeys,
|
||||||
onUploadDocuments,
|
onUploadDocuments,
|
||||||
identity,
|
identity,
|
||||||
onIdentityChange,
|
|
||||||
}: {
|
}: {
|
||||||
documentSettingCode: string;
|
documentSettingCode: string;
|
||||||
documentFiles?: Record<string, File | File[] | null>;
|
documentFiles?: Record<string, File | File[] | null>;
|
||||||
@@ -107,8 +106,6 @@ export default function CompanyProfileForm({
|
|||||||
>;
|
>;
|
||||||
/** Fayda verification state for the owner and the PoA (undefined until loaded). */
|
/** Fayda verification state for the owner and the PoA (undefined until loaded). */
|
||||||
identity?: CompanyIdentityState;
|
identity?: CompanyIdentityState;
|
||||||
/** Refetch the profile + requirements once a verification lands. */
|
|
||||||
onIdentityChange?: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
|
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -535,14 +532,16 @@ export default function CompanyProfileForm({
|
|||||||
const currentIdx = stepOrder.indexOf(step);
|
const currentIdx = stepOrder.indexOf(step);
|
||||||
|
|
||||||
// The DARS delegation paper is what proves the representative was actually
|
// The DARS delegation paper is what proves the representative was actually
|
||||||
// delegated, so it's required the moment a PoA exists — and unconditionally
|
// delegated, so it's required the moment a PoA exists. The API enforces the
|
||||||
// for a freight forwarder, whose PoA itself is mandatory. The API enforces
|
// same rule on save, so skipping it here only costs the customer a
|
||||||
// the same rule on save, so skipping it here only costs the customer a
|
|
||||||
// round-trip.
|
// round-trip.
|
||||||
// A PoA exists exactly when one has been verified — the details are the
|
// A PoA exists exactly when one has been verified — the details are the
|
||||||
// verification's output, so there is nothing else that could stand for one.
|
// verification's output, so there is nothing else that could stand for one.
|
||||||
|
// Until then the upload is hidden: there is no representative for the paper
|
||||||
|
// to authorise, and a freight forwarder is held on the verification gate
|
||||||
|
// below rather than on a file field it cannot yet fill.
|
||||||
const poaProvided = identity?.poa.verified ?? false;
|
const poaProvided = identity?.poa.verified ?? false;
|
||||||
const delegationRequired = requirePoa || poaProvided;
|
const delegationRequired = poaProvided;
|
||||||
const delegationPresent =
|
const delegationPresent =
|
||||||
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
||||||
(() => {
|
(() => {
|
||||||
@@ -714,7 +713,6 @@ export default function CompanyProfileForm({
|
|||||||
title="Owner"
|
title="Owner"
|
||||||
state={identity.owner}
|
state={identity.owner}
|
||||||
required={identity.faydaRequired}
|
required={identity.faydaRequired}
|
||||||
onVerified={() => onIdentityChange?.()}
|
|
||||||
/>
|
/>
|
||||||
{identity.passportRequired && (
|
{identity.passportRequired && (
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -872,7 +870,6 @@ export default function CompanyProfileForm({
|
|||||||
title="Power of Attorney"
|
title="Power of Attorney"
|
||||||
state={identity.poa}
|
state={identity.poa}
|
||||||
required={requirePoa}
|
required={requirePoa}
|
||||||
onVerified={() => onIdentityChange?.()}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* The city is the one field the Fayda address claim does not
|
{/* The city is the one field the Fayda address claim does not
|
||||||
@@ -884,7 +881,9 @@ export default function CompanyProfileForm({
|
|||||||
{...register("poaLocation")}
|
{...register("poaLocation")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{poaDocumentSetting && (
|
{/* The paper authorises the representative the verification
|
||||||
|
named, so it only has meaning once one exists. */}
|
||||||
|
{poaProvided && poaDocumentSetting && (
|
||||||
<>
|
<>
|
||||||
<Divider my="sm" />
|
<Divider my="sm" />
|
||||||
<SmartFileInput
|
<SmartFileInput
|
||||||
|
|||||||
@@ -379,11 +379,6 @@ export default function TabCompanyProfile({
|
|||||||
required={identity.faydaRequired}
|
required={identity.faydaRequired}
|
||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
pendingReview={pendingOwnerReview}
|
pendingReview={pendingOwnerReview}
|
||||||
onVerified={() =>
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: api.companies.getProfile.queryKey(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{identity.passportRequired && (
|
{identity.passportRequired && (
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
@@ -136,7 +136,12 @@ export default function TabPowerOfAttorney({
|
|||||||
// has been verified.
|
// has been verified.
|
||||||
const identity = profile.identity;
|
const identity = profile.identity;
|
||||||
const poaProvided = identity?.poa.verified ?? false;
|
const poaProvided = identity?.poa.verified ?? false;
|
||||||
const letterRequired = requirePoa || poaProvided;
|
// The paper authorises the representative named above, so there is nothing
|
||||||
|
// for it to authorise until one has been verified — the upload is hidden
|
||||||
|
// until then, and requiring it while hidden would block the save on a
|
||||||
|
// control the customer cannot see. A freight forwarder is still held to
|
||||||
|
// having a PoA at all, by the verification gate on the panel and by the API.
|
||||||
|
const letterRequired = poaProvided;
|
||||||
const letterMissing = letterRequired && !hasLetterAfterSave;
|
const letterMissing = letterRequired && !hasLetterAfterSave;
|
||||||
|
|
||||||
const fileDirty = Boolean(pickedFile) || removeIds.length > 0;
|
const fileDirty = Boolean(pickedFile) || removeIds.length > 0;
|
||||||
@@ -253,14 +258,6 @@ export default function TabPowerOfAttorney({
|
|||||||
state={identity.poa}
|
state={identity.poa}
|
||||||
required={requirePoa}
|
required={requirePoa}
|
||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
onVerified={() => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: api.companies.getProfile.queryKey(),
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: api.companies.poaDelegation.queryKey(),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -282,6 +279,9 @@ export default function TabPowerOfAttorney({
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* ------------------------ Delegation letter ------------------------ */}
|
{/* ------------------------ Delegation letter ------------------------ */}
|
||||||
|
{/* The paper authorises the representative the verification named,
|
||||||
|
so it only has meaning once one exists. */}
|
||||||
|
{poaProvided && (
|
||||||
<Stack gap="sm" mt="xl">
|
<Stack gap="sm" mt="xl">
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
@@ -429,6 +429,7 @@ export default function TabPowerOfAttorney({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
<Group
|
<Group
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
stashPendingVerification,
|
||||||
|
takePendingVerification,
|
||||||
|
} from "./verifayda.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stash is the only thing that survives the full-page handoff to eSignet,
|
||||||
|
* so /callback completing against the wrong subject — or throwing on junk left
|
||||||
|
* behind by an older build — would either misfile a verified identity or dead-
|
||||||
|
* end the flow.
|
||||||
|
*/
|
||||||
|
describe("pending verification stash", () => {
|
||||||
|
// The suite runs in node, not jsdom — a Map is all these two calls need.
|
||||||
|
beforeEach(() => {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
vi.stubGlobal("sessionStorage", {
|
||||||
|
getItem: (k: string) => store.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void store.set(k, v),
|
||||||
|
removeItem: (k: string) => void store.delete(k),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips and clears, so a spent code can't be replayed", () => {
|
||||||
|
stashPendingVerification({ subject: "poa", returnTo: "/settings?tab=poa" });
|
||||||
|
|
||||||
|
expect(takePendingVerification()).toEqual({
|
||||||
|
subject: "poa",
|
||||||
|
returnTo: "/settings?tab=poa",
|
||||||
|
});
|
||||||
|
expect(takePendingVerification()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null rather than throwing on missing or malformed entries", () => {
|
||||||
|
expect(takePendingVerification()).toBeNull();
|
||||||
|
|
||||||
|
sessionStorage.setItem("fayda-pending-verification", "not json");
|
||||||
|
expect(takePendingVerification()).toBeNull();
|
||||||
|
|
||||||
|
sessionStorage.setItem("fayda-pending-verification", '{"returnTo":"/"}');
|
||||||
|
expect(takePendingVerification()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -38,20 +38,46 @@ export interface CompanyIdentityState {
|
|||||||
complete: boolean;
|
complete: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Message posted from the /callback popup back to the opener window. */
|
/**
|
||||||
export interface FaydaCallbackMessage {
|
* What the panel was doing when it handed the tab over to eSignet. The
|
||||||
type: "fayda-callback";
|
* verification is a full-page redirect, so the page that started it is gone by
|
||||||
code?: string;
|
* the time /callback runs — this is how /callback knows whose identity the
|
||||||
state?: string;
|
* code+state belongs to and where to put the user back.
|
||||||
error?: string;
|
*
|
||||||
errorDescription?: string;
|
* sessionStorage, not localStorage: it is scoped to this tab, so two tabs
|
||||||
|
* verifying different people can't overwrite each other, and it dies with the
|
||||||
|
* tab rather than outliving an abandoned verification.
|
||||||
|
*/
|
||||||
|
const PENDING_KEY = "fayda-pending-verification";
|
||||||
|
|
||||||
|
export interface PendingVerification {
|
||||||
|
subject: IdentitySubject;
|
||||||
|
/** Path to return to once the verification completes. */
|
||||||
|
returnTo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stashPendingVerification(pending: PendingVerification): void {
|
||||||
|
sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read and clear — the code+state are single-use, so a retry needs a fresh start. */
|
||||||
|
export function takePendingVerification(): PendingVerification | null {
|
||||||
|
const raw = sessionStorage.getItem(PENDING_KEY);
|
||||||
|
sessionStorage.removeItem(PENDING_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as PendingVerification;
|
||||||
|
return parsed.subject ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const verifaydaService = {
|
export const verifaydaService = {
|
||||||
/**
|
/**
|
||||||
* Returns the eSignet authorize URL to open in a popup. `PORTAL` selects the
|
* Returns the eSignet authorize URL to navigate the tab to. `PORTAL` selects
|
||||||
* portal's own registered redirect_uri — the backoffice and mobile clients
|
* the portal's own registered redirect_uri — the backoffice and mobile
|
||||||
* have their own.
|
* clients have their own.
|
||||||
*/
|
*/
|
||||||
start: async (): Promise<string> => {
|
start: async (): Promise<string> => {
|
||||||
const response = await client.post<
|
const response = await client.post<
|
||||||
|
|||||||
Reference in New Issue
Block a user